Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add default model alias crud to admin ui #676

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions ui/admin/app/components/form/BasicInputItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,7 @@ import {
FormMessage,
} from "~/components/ui/form";

export function BasicInputItem({
children,
classNames = {},
label,
description,
}: {
export type BasicInputItemProps = {
children: ReactNode;
classNames?: {
wrapper?: string;
Expand All @@ -23,7 +18,14 @@ export function BasicInputItem({
};
label?: ReactNode;
description?: ReactNode;
}) {
};

export function BasicInputItem({
children,
classNames = {},
label,
description,
}: BasicInputItemProps) {
return (
<FormItem className={classNames.wrapper}>
{label && (
Expand Down
13 changes: 11 additions & 2 deletions ui/admin/app/components/form/controlledInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {

import { cn } from "~/lib/utils";

import { BasicInputItem } from "~/components/form/BasicInputItem";
import {
BasicInputItem,
BasicInputItemProps,
} from "~/components/form/BasicInputItem";
import { Checkbox } from "~/components/ui/checkbox";
import {
FormControl,
Expand Down Expand Up @@ -232,6 +235,7 @@ export type ControlledCustomInputProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = BaseProps<TValues, TName> & {
classNames?: BasicInputItemProps["classNames"];
children: (props: {
field: ControllerRenderProps<TValues, TName>;
fieldState: ControllerFieldState;
Expand All @@ -248,14 +252,19 @@ export function ControlledCustomInput<
name,
label,
description,
classNames,
children,
}: ControlledCustomInputProps<TValues, TName>) {
return (
<FormField
control={control}
name={name}
render={(args) => (
<BasicInputItem label={label} description={description}>
<BasicInputItem
classNames={classNames}
label={label}
description={description}
>
{children({
...args,
className: getFieldStateClasses(args.fieldState),
Expand Down
222 changes: 222 additions & 0 deletions ui/admin/app/components/model/DefaultModelAliasForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";

import { UpdateDefaultModelAlias } from "~/lib/model/defaultModelAliases";
import { Model, getModelUsageFromAlias } from "~/lib/model/models";
import { DefaultModelAliasApiService } from "~/lib/service/api/defaultModelAliasApiService";
import { ModelApiService } from "~/lib/service/api/modelApiService";

import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useAsync } from "~/hooks/useAsync";

export function DefaultModelAliasForm({
onSuccess,
}: {
onSuccess?: () => void;
}) {
const { data: defaultAliases } = useSWR(
DefaultModelAliasApiService.getAliases.key(),
DefaultModelAliasApiService.getAliases
);

const { data: models } = useSWR(
ModelApiService.getModels.key(),
ModelApiService.getModels
);

const modelUsageMap = useMemo(() => {
return (models ?? []).reduce((acc, model) => {
if (!acc.has(model.usage)) acc.set(model.usage, []);

acc.get(model.usage)?.push(model);

return acc;
}, new Map<string, Model[]>());
}, [models]);

const update = useAsync(
async (updates: UpdateDefaultModelAlias[]) => {
await Promise.all(
updates.map((update) =>
DefaultModelAliasApiService.updateAlias(
update.alias,
update
)
)
);
},
{
onSuccess: () => {
toast.success("Default model aliases updated");
onSuccess?.();
},
}
);

const defaultValues = useMemo(() => {
return defaultAliases?.reduce(
(acc, alias) => {
acc[alias.alias] = alias.model;
return acc;
},
{} as Record<string, string>
);
}, [defaultAliases]);

const form = useForm<Record<string, string>>({ defaultValues });

useEffect(() => {
return form.watch((values) => {
const changedItems = defaultAliases?.filter(({ alias, model }) => {
return values[alias] !== model;
});

if (!changedItems?.length) return;
}).unsubscribe;
}, [defaultAliases, form]);

useEffect(() => {
form.reset(defaultValues);
}, [defaultValues, form]);

const handleSubmit = form.handleSubmit((values) => {
const updates = defaultAliases
?.filter(({ alias, model }) => values[alias] !== model)
.map(({ alias }) => ({
alias,
model: values[alias],
}));

update.execute(updates ?? []);
});

return (
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-6">
{defaultAliases?.map(({ alias, model: defaultModel }) => (
<FormField
control={form.control}
name={alias}
key={alias}
render={({ field: { ref: _, ...field } }) => {
const usage = getModelUsageFromAlias(alias);
const modelOptions = usage
? modelUsageMap.get(usage)
: [];

return (
<FormItem className="flex justify-between items-center space-y-0">
<FormLabel>{alias}</FormLabel>

<div className="flex flex-col gap-2 w-[50%]">
<FormControl>
ryanhopperlowe marked this conversation as resolved.
Show resolved Hide resolved
<Select
{...field}
key={field.value}
value={field.value || ""}
onValueChange={field.onChange}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
defaultModel
}
/>
</SelectTrigger>

<SelectContent>
{modelOptions ? (
modelOptions.map(
(model) => (
<SelectItem
key={
model.id
}
value={
model.id
}
>
{model.id}
</SelectItem>
)
)
) : (
<SelectItem
value={defaultModel}
>
{defaultModel}
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>

<FormMessage />
</div>
</FormItem>
);
}}
/>
))}

<Button
type="submit"
className="w-full"
disabled={update.isLoading}
loading={update.isLoading}
>
Save Changes
</Button>
</form>
</Form>
);
}

export function DefaultModelAliasFormDialog() {
const [open, setOpen] = useState(false);

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Default Model Aliases</Button>
</DialogTrigger>

<DialogContent>
<DialogHeader>
<DialogTitle>Default Model Aliases</DialogTitle>
</DialogHeader>

<DialogDescription>
Set the default model for each usage.
</DialogDescription>

<DefaultModelAliasForm onSuccess={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
17 changes: 3 additions & 14 deletions ui/admin/app/components/model/ModelForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,9 @@ import {
getModelUsageLabel,
getModelsForProvider,
} from "~/lib/model/models";
import { BadRequestError } from "~/lib/service/api/apiErrors";
import { ModelApiService } from "~/lib/service/api/modelApiService";

import {
ControlledCheckbox,
ControlledCustomInput,
} from "~/components/form/controlledInputs";
import { ControlledCustomInput } from "~/components/form/controlledInputs";
import { Button } from "~/components/ui/button";
import { Form } from "~/components/ui/form";
import {
Expand Down Expand Up @@ -71,7 +67,6 @@ export function ModelForm(props: ModelFormProps) {
targetModel: model?.targetModel ?? "",
modelProvider: model?.modelProvider ?? "",
active: model?.active ?? true,
default: model?.default ?? false,
usage: model?.usage ?? ModelUsage.LLM,
};
}, [model]);
Expand Down Expand Up @@ -184,12 +179,6 @@ export function ModelForm(props: ModelFormProps) {
)}
</ControlledCustomInput>

<ControlledCheckbox
control={form.control}
name="default"
label="Default Model"
/>

<Button
type="submit"
className="w-full"
Expand Down Expand Up @@ -220,7 +209,7 @@ export function ModelForm(props: ModelFormProps) {
}

function onError(error: unknown) {
if (error instanceof BadRequestError)
form.setError("default", { message: error.message });
if (error instanceof Error) toast.error(error.message);
else toast.error("Model failed to save.");
}
}
9 changes: 9 additions & 0 deletions ui/admin/app/lib/model/defaultModelAliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type DefaultModelAliasBase = {
alias: string;
model: string;
};

export type DefaultModelAlias = DefaultModelAliasBase;

export type CreateDefaultModelAlias = DefaultModelAliasBase;
export type UpdateDefaultModelAlias = DefaultModelAliasBase;
15 changes: 13 additions & 2 deletions ui/admin/app/lib/model/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export type ModelManifest = {
targetModel?: string;
modelProvider: string;
active: boolean;
default: boolean;
usage: ModelUsage;
};

Expand All @@ -42,7 +41,6 @@ export const ModelManifestSchema = z.object({
targetModel: z.string().min(1, "Required"),
modelProvider: z.string().min(1, "Required"),
active: z.boolean(),
default: z.boolean(),
usage: z.nativeEnum(ModelUsage),
});

Expand Down Expand Up @@ -91,6 +89,19 @@ const ModelToProviderMap = {
],
};

export const ModelAliasToUsageMap = {
llm: ModelUsage.LLM,
"llm-mini": ModelUsage.LLM,
"text-embedding": ModelUsage.TextEmbedding,
"image-generation": ModelUsage.ImageGeneration,
} as const;

export function getModelUsageFromAlias(alias: string) {
if (!(alias in ModelAliasToUsageMap)) return null;

return ModelAliasToUsageMap[alias as keyof typeof ModelAliasToUsageMap];
}

export function getModelsForProvider(providerId: string) {
if (!providerId || !(providerId in ModelToProviderMap)) return [];
return ModelToProviderMap[providerId as keyof typeof ModelToProviderMap];
Expand Down
Loading