import { MinusCircle, PlusCircle, X } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod/v4"; import { CodeMirrorEditor } from "@/src/components/editor"; import { Button } from "@/src/components/ui/button"; import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, } from "@/src/components/ui/drawer"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/src/components/ui/form"; import { Input } from "@/src/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/src/components/ui/select"; import { type FormUpsertModel, FormUpsertModelSchema, type GetModelResult, } from "@/src/features/models/validation"; import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture"; import { api } from "@/src/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/router"; import { PricePreview } from "./PricePreview"; import { showSuccessToast } from "@/src/features/notifications/showSuccessToast"; type UpsertModelDrawerProps = | { action: "create"; children: React.ReactNode; projectId: string; prefilledModelData?: { modelName?: string; prices?: Record; }; className?: string; } | { action: "edit" | "clone"; children: React.ReactNode; projectId: string; modelData: GetModelResult; className?: string; }; export const UpsertModelFormDrawer = ({ children, ...props }: UpsertModelDrawerProps) => { const capture = usePostHogClientCapture(); const router = useRouter(); const [formError, setFormError] = useState(null); const utils = api.useUtils(); const [open, setOpen] = useState(false); let defaultValues: FormUpsertModel; if (props.action !== "create") { defaultValues = { modelName: props.modelData.modelName, matchPattern: props.modelData.matchPattern, tokenizerId: props.modelData.tokenizerId, tokenizerConfig: JSON.stringify(props.modelData.tokenizerConfig ?? {}), prices: props.modelData.prices, }; } else { defaultValues = { modelName: props.prefilledModelData?.modelName ?? "", matchPattern: props.prefilledModelData?.modelName ? `(?i)^(${props.prefilledModelData?.modelName})$` : "", tokenizerId: null, tokenizerConfig: null, prices: props.prefilledModelData?.prices ?? { input: 0.000001, output: 0.000002, }, }; } const form = useForm({ resolver: zodResolver( props.action === "edit" ? FormUpsertModelSchema.omit({ modelName: true }).extend({ modelName: z.string().default(props.modelData.modelName), }) : FormUpsertModelSchema, ), defaultValues, }); const modelName = form.watch("modelName"); const matchPattern = form.watch("matchPattern"); const tokenizerId = form.watch("tokenizerId"); // prefill match pattern if model name changes useEffect(() => { const getRegexString = (modelName: string) => `(?i)^(${modelName})$`; if ( modelName && (!matchPattern || matchPattern === `(?i)^(${modelName.slice(0, -1)})$` || matchPattern === `(?i)^(${modelName})$`) ) { form.setValue("matchPattern", getRegexString(modelName)); } }, [modelName, matchPattern, form]); const upsertModelMutation = api.models.upsert.useMutation({ onSuccess: (upsertedModel) => { utils.models.invalidate(); form.reset(); setOpen(false); showSuccessToast({ title: `Model ${props.action === "edit" ? "updated" : "created"}`, description: `The model '${upsertedModel.modelName}' has been successfully ${props.action === "edit" ? "updated" : "created"}. New generations will use these model prices.`, }); router.push( `/project/${props.projectId}/settings/models/${upsertedModel.id}`, ); }, onError: (error) => setFormError(error.message), }); const onSubmit = async (values: FormUpsertModel) => { capture("models:new_form_submit"); await upsertModelMutation .mutateAsync({ modelId: props.action === "edit" ? props.modelData.id : null, projectId: props.projectId, modelName: props.action === "edit" ? props.modelData.modelName : values.modelName, matchPattern: values.matchPattern, prices: values.prices, tokenizerId: values.tokenizerId, tokenizerConfig: values.tokenizerConfig && typeof JSON.parse(values.tokenizerConfig) === "object" ? (JSON.parse(values.tokenizerConfig) as Record) : undefined, }) .catch((error) => { setFormError(error.message); }); }; return ( { if (!open) return; // Only allow closing via cancel key setOpen(open); }} dismissible={false} onClose={() => { form.reset(); setFormError(null); }} > setOpen(true)} className={props.className} title={ props.action === "create" ? "Create model definition" : "Edit model definition" } > {children}
{props.action === "create" ? "Create Model" : props.action === "clone" ? "Clone Model" : "Edit Model"}
{props.action === "edit" ? props.modelData.modelName : props.action === "create" ? "Create a new model configuration to track generation costs." : null}
( Model Name The name of the model. This will be used to reference the model in the API. You can track price changes of models by using the same name and match pattern. )} /> ( Match pattern Regular expression (Postgres syntax) to match ingested generations (model attribute) to this model definition. For an exact, case-insensitive match to a model name, use the expression: (?i)^(modelname)$ )} /> ( Prices Set prices per usage type for this model. Usage types must exactly match the keys of the ingested usage details. Prefill usage types from template: Usage type Price {Object.entries(field.value).map( ([key, value], index) => (
{ const newPrices = { ...field.value }; const oldValue = newPrices[key]; delete newPrices[key]; newPrices[e.target.value] = oldValue; field.onChange(newPrices); }} />
{ field.onChange({ ...field.value, [key]: parseFloat(e.target.value), }); }} />
), )}
)} /> ( Tokenizer Optionally, Langfuse can tokenize the input and output of a generation if no unit counts are ingested. This is useful for e.g. streamed OpenAI completions. For details on the supported tokenizers, see the{" "} docs . )} /> {tokenizerId && tokenizerId !== "None" && ( ( Tokenizer Config The config for the tokenizer. Required for openai. See the{" "} docs {" "} for details. )} /> )} {formError ? (

Error: {formError}

) : null}
); };