From 4fa4cd8a08a9ab674f328c0aabf409cd921de63b Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 22 Apr 2026 20:34:09 -0700 Subject: [PATCH] fix(evals-ui): align criterion name+weight inputs with flex-end; add /evals route --- .../src/app/(dashboard)/evals/page.tsx | 12 + .../src/components/evals/EvalsCatalog.tsx | 492 ++++++++++++++---- 2 files changed, 416 insertions(+), 88 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/evals/page.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/evals/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/evals/page.tsx new file mode 100644 index 00000000000..fe8ee5b304f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/evals/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import EvalsCatalog from "@/components/evals/EvalsCatalog"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const EvalsPage = () => { + const { accessToken } = useAuthorized(); + + return ; +}; + +export default EvalsPage; diff --git a/ui/litellm-dashboard/src/components/evals/EvalsCatalog.tsx b/ui/litellm-dashboard/src/components/evals/EvalsCatalog.tsx index 64d7a74d78b..538263f0dad 100644 --- a/ui/litellm-dashboard/src/components/evals/EvalsCatalog.tsx +++ b/ui/litellm-dashboard/src/components/evals/EvalsCatalog.tsx @@ -1,11 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; import { - Button, - Table, - Tag, - Typography, - Space, + Modal, Drawer, Form, Input, @@ -13,11 +9,30 @@ import { Select, Popconfirm, message, + Tag, + Tooltip, + Typography, } from "antd"; -import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; -import { createLiteLLMEval, listLiteLLMEvals, deleteLiteLLMEval, modelAvailableCall } from "../networking"; - -const { Title, Text } = Typography; +import { Button } from "@tremor/react"; +import { PlusOutlined, DeleteOutlined, QuestionCircleOutlined, RobotOutlined } from "@ant-design/icons"; +import { TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from "@tremor/react"; +import { createLiteLLMEval, listLiteLLMEvals, deleteLiteLLMEval, updateLiteLLMEval, modelAvailableCall } from "../networking"; interface EvalCriterion { name: string; @@ -47,10 +62,14 @@ interface Props { export default function EvalsCatalog({ accessToken, userRole, availableModels: availableModelsProp = [] }: Props) { const [evals, setEvals] = useState([]); const [loading, setLoading] = useState(false); - const [drawerOpen, setDrawerOpen] = useState(false); + const [modalOpen, setModalOpen] = useState(false); const [saving, setSaving] = useState(false); + const [editEval, setEditEval] = useState(null); + const [editSaving, setEditSaving] = useState(false); const [availableModels, setAvailableModels] = useState(availableModelsProp); + const [sorting, setSorting] = useState([]); const [form] = Form.useForm(); + const [editForm] = Form.useForm(); const fetchEvals = async () => { if (!accessToken) return; @@ -68,11 +87,11 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a const fetchModels = async () => { if (!accessToken || availableModelsProp.length > 0) return; try { - const data = await modelAvailableCall(accessToken, null, null); + const data = await modelAvailableCall(accessToken, "", ""); const names: string[] = data?.data?.map((m: any) => m.id) ?? []; setAvailableModels(names); } catch { - // best-effort; leave empty + // best-effort } }; @@ -94,7 +113,7 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a max_iterations: values.max_iterations ?? 1, }); message.success("Eval created"); - setDrawerOpen(false); + setModalOpen(false); form.resetFields(); fetchEvals(); } catch (e: any) { @@ -104,6 +123,45 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a } }; + const handleEdit = (ev: LiteLLMEval) => { + setEditEval(ev); + editForm.setFieldsValue({ + eval_name: ev.eval_name, + description: ev.description, + judge_model: ev.judge_model, + overall_threshold: ev.overall_threshold ?? 80, + max_iterations: ev.max_iterations ?? 1, + criteria: (ev.criteria || []).map((c: any) => ({ + name: c.name, + weight: c.weight, + description: c.description, + })), + }); + }; + + const handleUpdate = async (values: any) => { + if (!accessToken || !editEval) return; + setEditSaving(true); + try { + await updateLiteLLMEval(accessToken, editEval.eval_id, { + eval_name: values.eval_name, + criteria: values.criteria || [], + judge_model: values.judge_model, + description: values.description, + overall_threshold: values.overall_threshold ?? 80, + max_iterations: values.max_iterations ?? 1, + }); + message.success("Eval updated"); + setEditEval(null); + editForm.resetFields(); + fetchEvals(); + } catch (e: any) { + message.error(`Failed to update eval: ${e.message}`); + } finally { + setEditSaving(false); + } + }; + const handleDelete = async (evalId: string) => { if (!accessToken) return; try { @@ -115,107 +173,222 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a } }; - const columns = [ + const columns: ColumnDef[] = [ { - title: "Name", - dataIndex: "eval_name", - key: "eval_name", - render: (v: string) => {v}, + header: "Name", + accessorKey: "eval_name", + cell: ({ row }) => ( + handleEdit(row.original)} + > + {row.original.eval_name} + + ), }, { - title: "Judge Model", - dataIndex: "judge_model", - key: "judge_model", + header: "Judge Model", + accessorKey: "judge_model", + cell: ({ row }) => ( + {row.original.judge_model} + ), }, { - title: "Minimum Score to Pass", - dataIndex: "overall_threshold", - key: "overall_threshold", - render: (v?: number) => - v != null ? ≥ {v} / 100 : , + header: "Minimum Score to Pass", + accessorKey: "overall_threshold", + cell: ({ row }) => { + const v = row.original.overall_threshold; + return v != null ? ( + ≥ {v} / 100 + ) : ( + + ); + }, }, { - title: "Criteria", - dataIndex: "criteria", - key: "criteria", - render: (v: EvalCriterion[]) => {v?.length ?? 0} criteria, + header: "Criteria", + accessorKey: "criteria", + cell: ({ row }) => ( + {row.original.criteria?.length ?? 0} criteria + ), }, { - title: "Version", - dataIndex: "version", - key: "version", - render: (v: number) => v{v}, + header: "Version", + accessorKey: "version", + cell: ({ row }) => ( + v{row.original.version} + ), }, { - title: "Actions", - key: "actions", - render: (_: any, record: LiteLLMEval) => ( + id: "actions", + header: "Actions", + cell: ({ row }) => ( handleDelete(record.eval_id)} + onConfirm={() => handleDelete(row.original.eval_id)} okText="Delete" okButtonProps={{ danger: true }} > - ), }, ]; + const table = useReactTable({ + data: evals, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableSorting: true, + }); + return ( -
-
- - - Evals - - Beta - -
- +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanSort() && ( + + {header.column.getIsSorted() === "asc" ? ( + + ) : header.column.getIsSorted() === "desc" ? ( + + ) : ( + + )} + + )} +
+
+ ))} +
+ ))} +
+ + {loading ? ( + + + Loading... + + + ) : evals.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No evals yet. Create one to get started. + + + )} + +
+
- +
Create Eval - Beta - + Beta +
} - open={drawerOpen} - onClose={() => { - setDrawerOpen(false); + open={modalOpen} + width={700} + onCancel={() => { + setModalOpen(false); form.resetFields(); }} - width={560} footer={ -
- -
} + className="top-8" + styles={{ + body: { padding: "24px" }, + header: { padding: "24px 24px 0 24px", border: "none" }, + }} >
+ {/* How it works banner */} +
+ + + After each agent response, the Judge Model (an LLM you choose) reads the response and scores it 0–100 against each criterion. The weighted average of those scores is the final score. If it falls below the threshold, the response is blocked. + +
+ @@ -224,7 +397,18 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a - + + Judge Model  + + + + + } + rules={[{ required: true }]} + > + + + + + + + + + + +
+ +
+ + + + + + ))} + + {fields.length > 0 && ( + + {() => { + const c: any[] = editForm.getFieldValue("criteria") || []; + const total = c.reduce((s: number, x: any) => s + (Number(x?.weight) || 0), 0); + return ( +
+ Weights total: {total}%{total === 100 ? " ✓" : " — should add up to 100%"} +
+ ); + }} +
+ )} + + )} + +
+ + + +
);