Compare commits

...
13 Commits
Author SHA1 Message Date
Marc Klingen dcd6060665 chore: release v3.4.0 2024-12-20 22:41:08 +01:00
Chris TranandGitHub 0bfba7d690 feat(auth): add AUTH_CUSTOM_CLIENT_AUTH_METHOD to support PKCE code flow (#4810) 2024-12-20 22:34:02 +01:00
Hassieb Pakzad 25b363e1bc chore: fix eslint error 2024-12-20 18:54:34 +01:00
Hassieb PakzadandGitHub 0de37b0437 feat(models): add UI to add all usage types (#4802) 2024-12-20 18:49:44 +01:00
Max DeichmannandGitHub ab5febb9c7 fix: include time filter into user metrics query (#4808)
* fixes: fix user table sorting and filters

* fixes: fix user table sorting and filters

* fixes: fix user table sorting and filters
2024-12-20 15:59:11 +00:00
marliessophieandGitHub 0e5cade9e7 chore(ui): toggle json editor for text prompts (#4807) 2024-12-20 15:22:28 +00:00
Max DeichmannandGitHub 1438e052df fix: fix json parsing of trace / observation i/o (#4805)
* remove only

* push

* remove only
2024-12-20 12:54:47 +00:00
Max DeichmannandGitHub 9917aff8cf fix: add latencies to trace exports (#4804)
* fix: add latencies to trace exports

* remove only
2024-12-20 12:23:52 +00:00
marliessophieandGitHub c915c2b678 chore(ui): move dataset details into popover on runs table (#4801)
* chore(ui): move dataset details into popover on runs table

* push
2024-12-20 11:41:38 +00:00
marliessophieandGitHub 50379bd4b5 feat(datasets): support charts for scores and resource metrics (#4754)
* fixup: dataset run analytics; compare

* refactor: extract common timeseries component

* push

* fix: proper limit and offset settings

* chore: extend route to accept run ids

* eslint

* chore: order chart data ASC

* styles

* push

* push

* style: adjust multi-select component

* push

* push

* push
2024-12-20 10:14:37 +00:00
Marc KlingenandGitHub 8fd93ecff9 fix(cloud): remove unnecessary posthog events (#4798) 2024-12-20 01:59:39 +01:00
Max DeichmannandGitHub 046c6c6125 fix: fix tests (#4797) 2024-12-20 00:04:31 +01:00
Max DeichmannandGitHub ba750a2d55 fix: fix time to first token in trace detail view (#4796) 2024-12-19 23:41:40 +01:00
66 changed files with 2922 additions and 1168 deletions
+1
View File
@@ -101,6 +101,7 @@ OTEL_SERVICE_NAME="langfuse"
# AUTH_CUSTOM_ISSUER=
# AUTH_CUSTOM_NAME=
# AUTH_CUSTOM_SCOPE="openid email profile" # optional
# AUTH_CUSTOM_CLIENT_AUTH_METHOD="client_secret_basic" # optional
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
# Transactional email, optional
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.3.0",
"version": "3.4.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+13 -1
View File
@@ -112,6 +112,14 @@ export const observationsTableCols: ColumnDefinition[] = [
options: [], // to be added at runtime
nullable: true,
},
{
name: "Model ID",
id: "modelId",
type: "stringOptions",
internal: 'o."internal_model_id"',
options: [], // to be added at runtime
nullable: true,
},
{
name: "Input Tokens",
id: "inputTokens",
@@ -186,6 +194,7 @@ export const observationsTableCols: ColumnDefinition[] = [
// allows for undefined options, to offer filters while options are still loading
export type ObservationOptions = {
model: Array<OptionsDefinition>;
modelId: Array<OptionsDefinition>;
name: Array<OptionsDefinition>;
traceName: Array<OptionsDefinition>;
scores_avg: Array<string>;
@@ -194,12 +203,15 @@ export type ObservationOptions = {
};
export function observationsTableColsWithOptions(
options?: ObservationOptions
options?: ObservationOptions,
): ColumnDefinition[] {
return observationsTableCols.map((col) => {
if (col.id === "model") {
return { ...col, options: options?.model ?? [] };
}
if (col.id === "modelId") {
return { ...col, options: options?.modelId ?? [] };
}
if (col.id === "name") {
return { ...col, options: options?.name ?? [] };
}
@@ -750,6 +750,50 @@ export const getObservationsGroupedByModel = async (
return res.map((r) => ({ model: r.name }));
};
export const getObservationsGroupedByModelId = async (
projectId: string,
filter: FilterState,
) => {
const observationsFilter = new FilterList([
new StringFilter({
clickhouseTable: "observations",
field: "project_id",
operator: "=",
value: projectId,
tablePrefix: "o",
}),
]);
observationsFilter.push(
...createFilterFromFilterState(
filter,
observationsTableUiColumnDefinitions,
),
);
const appliedObservationsFilter = observationsFilter.apply();
// We mainly use queries like this to retrieve filter options.
// Therefore, we can skip final as some inaccuracy in count is acceptable.
const query = `
SELECT o.internal_model_id as modelId
FROM observations o
WHERE ${appliedObservationsFilter.query}
AND o.type = 'GENERATION'
GROUP BY o.internal_model_id
ORDER BY count() DESC
LIMIT 1000;
`;
const res = await queryClickhouse<{ modelId: string }>({
query,
params: {
...appliedObservationsFilter.params,
},
});
return res.map((r) => ({ modelId: r.modelId }));
};
export const getObservationsGroupedByName = async (
projectId: string,
filter: FilterState,
@@ -126,10 +126,11 @@ export const convertObservation = (
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()
: null,
timeToFirstToken: record.completion_start_time
? parseClickhouseUTCDateTimeFormat(
? (parseClickhouseUTCDateTimeFormat(
record.completion_start_time,
).getTime() -
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()) /
1000
: null,
};
};
@@ -680,10 +680,25 @@ export const getTotalUserCount = async (
});
};
export const getUserMetrics = async (projectId: string, userIds: string[]) => {
export const getUserMetrics = async (
projectId: string,
userIds: string[],
filter: FilterState,
) => {
if (userIds.length === 0) {
return [];
}
// filter state contains date range filter for traces so far.
const chFilter = new FilterList(
createFilterFromFilterState(filter, tracesTableUiColumnDefinitions),
);
const chFilterRes = chFilter.apply();
const timestampFilter = chFilter.find(
(f) => f.field === "timestamp" && f.operator === ">=",
);
// this query uses window functions on observations + traces to always get only the first row and thereby remove deduplicates
// we filter wherever possible by project id and user id
const query = `
@@ -713,6 +728,7 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
observations o
WHERE
o.project_id = {projectId: String }
${timestampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
AND o.trace_id in (
SELECT
distinct id
@@ -721,6 +737,7 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
where
user_id IN ({userIds: Array(String) })
AND project_id = {projectId: String }
${filter.length > 0 ? `AND ${chFilterRes.query}` : ""}
)
AND o.type = 'GENERATION'
) as o
@@ -740,6 +757,7 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
WHERE
t.user_id IN ({userIds: Array(String) })
AND t.project_id = {projectId: String }
${filter.length > 0 ? `AND ${chFilterRes.query}` : ""}
) as t on t.id = o.trace_id
and t.project_id = o.project_id
WHERE
@@ -778,8 +796,17 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
params: {
projectId,
userIds,
...chFilterRes.params,
...(timestampFilter
? {
traceTimestamp: convertDateToClickhouseDateTime(
(timestampFilter as DateTimeFilter).value,
),
}
: {}),
},
});
return rows.map((row) => ({
userId: row.user_id,
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
@@ -1,7 +1,9 @@
import { Trace } from "@prisma/client";
import { Prisma, Trace } from "@prisma/client";
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
import { TraceRecordReadType } from "./definitions";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import { parseJsonPrioritised } from "../../utils/json";
import { jsonSchema } from "../../utils/zod";
export const convertTraceDomainToClickhouse = (
trace: Trace,
@@ -43,8 +45,12 @@ export const convertClickhouseToDomain = (
userId: record.user_id ?? null,
sessionId: record.session_id ?? null,
public: record.public,
input: record.input ?? null,
output: record.output ?? null,
input: (record.input
? jsonSchema.parse(parseJsonPrioritised(record.input))
: null) as Prisma.JsonValue | null,
output: (record.output
? jsonSchema.parse(parseJsonPrioritised(record.output))
: null) as Prisma.JsonValue | null,
metadata: record.metadata,
createdAt: parseClickhouseUTCDateTimeFormat(record.created_at),
updatedAt: parseClickhouseUTCDateTimeFormat(record.updated_at),
@@ -127,6 +127,12 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
clickhouseTableName: "observations",
clickhouseSelect: 'o."provided_model_name"',
},
{
uiTableName: "Model ID",
uiTableId: "modelId",
clickhouseTableName: "observations",
clickhouseSelect: 'o."internal_model_id"',
},
{
uiTableName: "Input Tokens",
uiTableId: "inputTokens",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.3.0",
"version": "3.4.0",
"private": true,
"license": "MIT",
"engines": {
@@ -244,7 +244,7 @@ describe("Fetch datasets for UI presentation", () => {
expect(JSON.stringify(secondRun.scores)).toEqual(JSON.stringify({}));
});
it.only("should test that dataset runs can link to the same traces", async () => {
it("should test that dataset runs can link to the same traces", async () => {
const datasetId = v4();
await prisma.dataset.create({
@@ -154,8 +154,9 @@ describe("Clickhouse Observations Repository Test", () => {
expect(firstObservation.promptId).toEqual(observation.prompt_id);
expect(firstObservation.endTime).toEqual(new Date(observation.end_time));
expect(firstObservation.timeToFirstToken).toEqual(
new Date(observation.completion_start_time).getTime() -
new Date(observation.start_time).getTime(),
(new Date(observation.completion_start_time).getTime() -
new Date(observation.start_time).getTime()) /
1000,
);
expect(firstObservation.timeToFirstToken).toBeGreaterThan(0);
expect(firstObservation.calculatedTotalCost).toEqual(
@@ -35,6 +35,14 @@ describe("Clickhouse Traces Repository Test", () => {
release: null,
version: null,
user_id: null,
input: JSON.stringify({
this: {
is: {
a: ["complex", "object"],
},
},
}),
output: "regular string",
created_at: Date.now(),
updated_at: Date.now(),
event_ts: Date.now(),
@@ -63,8 +71,8 @@ describe("Clickhouse Traces Repository Test", () => {
expect(result.userId).toEqual(trace.user_id);
expect(result.sessionId).toEqual(trace.session_id);
expect(result.public).toEqual(trace.public);
expect(result.input).toEqual(null);
expect(result.output).toEqual(null);
expect(result.input).toEqual(JSON.parse(trace.input));
expect(result.output).toEqual("regular string");
expect(result.metadata).toEqual(trace.metadata);
expect(result.createdAt).toEqual(new Date(trace.created_at));
expect(result.updatedAt).toEqual(new Date(trace.updated_at));
@@ -52,7 +52,7 @@ describe("getUserMetrics function", () => {
await createObservationsInClickhouse([observation1, observation2]);
const userMetrics = await getUserMetrics(projectId, [userId]);
const userMetrics = await getUserMetrics(projectId, [userId], []);
expect(userMetrics.length).toBe(1);
expect(userMetrics[0]).toMatchObject({
+13 -1
View File
@@ -56,6 +56,7 @@ interface DataTableProps<TData, TValue> {
paginationClassName?: string;
isBorderless?: boolean;
shouldRenderGroupHeaders?: boolean;
onRowClick?: (row: TData) => void;
}
export interface AsyncTableData<T> {
@@ -108,6 +109,7 @@ export function DataTable<TData extends object, TValue>({
paginationClassName,
isBorderless = false,
shouldRenderGroupHeaders = false,
onRowClick,
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const rowheighttw = getRowHeightTailwindClass(rowHeight);
@@ -319,6 +321,7 @@ export function DataTable<TData extends object, TValue>({
columns={columns}
data={data}
help={help}
onRowClick={onRowClick}
/>
) : (
<TableBodyComponent
@@ -327,6 +330,7 @@ export function DataTable<TData extends object, TValue>({
columns={columns}
data={data}
help={help}
onRowClick={onRowClick}
/>
)}
</Table>
@@ -368,6 +372,7 @@ interface TableBodyComponentProps<TData> {
columns: LangfuseColumnDef<TData, any>[];
data: AsyncTableData<TData[]>;
help?: { description: string; href: string };
onRowClick?: (row: TData) => void;
}
function TableBodyComponent<TData>({
@@ -376,6 +381,7 @@ function TableBodyComponent<TData>({
columns,
data,
help,
onRowClick,
}: TableBodyComponentProps<TData>) {
return (
<TableBody>
@@ -390,7 +396,13 @@ function TableBodyComponent<TData>({
</TableRow>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
<TableRow
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={
onRowClick ? "cursor-pointer hover:bg-accent" : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
@@ -42,7 +42,8 @@ import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrde
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
import { InfoIcon } from "lucide-react";
import { InfoIcon, PlusCircle } from "lucide-react";
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
export type GenerationsTableRow = {
id: string;
@@ -83,6 +84,7 @@ export type GenerationsTableProps = {
projectId: string;
promptName?: string;
promptVersion?: number;
modelId?: string;
omittedFilter?: string[];
};
@@ -90,6 +92,7 @@ export default function GenerationsTable({
projectId,
promptName,
promptVersion,
modelId,
omittedFilter = [],
}: GenerationsTableProps) {
const [searchQuery, setSearchQuery] = useQueryParam(
@@ -143,6 +146,17 @@ export default function GenerationsTable({
]
: [];
const modelIdFilter: FilterState = modelId
? [
{
column: "Model ID",
type: "string",
operator: "=",
value: modelId,
},
]
: [];
const dateRangeFilter: FilterState = dateRange
? [
{
@@ -158,6 +172,7 @@ export default function GenerationsTable({
...dateRangeFilter,
...promptNameFilter,
...promptVersionFilter,
...modelIdFilter,
]);
const getCountPayload = {
@@ -447,7 +462,56 @@ export default function GenerationsTable({
size: 150,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
const model = row.getValue("model") as string;
const modelId = row.getValue("modelId") as string | undefined;
if (!model) return null;
return modelId ? (
<TableLink
path={`/project/${projectId}/models/${modelId}`}
value={model}
/>
) : (
<UpsertModelFormDrawer
action="create"
projectId={projectId}
prefilledModelData={{
modelName: model,
prices:
Object.keys(row.original.usageDetails).length > 0
? Object.keys(row.original.usageDetails)
.filter((key) => key != "total")
.reduce(
(acc, key) => {
acc[key] = 0.000001;
return acc;
},
{} as Record<string, number>,
)
: undefined,
}}
className="cursor-pointer"
>
<span className="flex items-center gap-1">
<span>{model}</span>
<PlusCircle className="h-3 w-3" />
</span>
</UpsertModelFormDrawer>
);
},
},
{
accessorKey: "modelId",
id: "modelId",
header: "Model ID",
size: 100,
enableHiding: true,
defaultHidden: true,
},
{
accessorKey: "inputTokens",
id: "inputTokens",
@@ -691,6 +755,7 @@ export default function GenerationsTable({
name: generation.name ?? undefined,
version: generation.version ?? "",
model: generation.model ?? "",
modelId: generation.modelId ?? undefined,
level: generation.level,
statusMessage: generation.statusMessage ?? undefined,
usage: {
+120 -218
View File
@@ -1,39 +1,40 @@
import { useEffect } from "react";
import { DataTable } from "@/src/components/table/data-table";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { Button } from "@/src/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { useState } from "react";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { api } from "@/src/utils/api";
import { usdFormatter } from "@/src/utils/numbers";
import { type Prisma, type Model } from "@langfuse/shared/src/db";
import Decimal from "decimal.js";
import { Trash } from "lucide-react";
import { type Prisma } from "@langfuse/shared/src/db";
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import { cn } from "@/src/utils/tailwind";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
import { type GetModelResult } from "@/src/features/models/validation";
import { DeleteModelButton } from "@/src/features/models/components/DeleteModelButton";
import { EditModelButton } from "@/src/features/models/components/EditModelButton";
import { CloneModelButton } from "@/src/features/models/components/CloneModelButton";
import { PriceBreakdownTooltip } from "@/src/features/models/components/PriceBreakdownTooltip";
import { UserCircle2Icon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/src/components/ui/tooltip";
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
import { useRouter } from "next/router";
import { PriceUnitSelector } from "@/src/features/models/components/PriceUnitSelector";
import { usePriceUnitMultiplier } from "@/src/features/models/hooks/usePriceUnitMultiplier";
export type ModelTableRow = {
modelId: string;
maintainer: string;
modelName: string;
matchPattern: string;
startDate?: Date;
inputPrice?: Decimal;
outputPrice?: Decimal;
totalPrice?: Decimal;
unit: string;
prices?: Record<string, number>;
tokenizerId?: string;
config?: Prisma.JsonValue;
serverResponse: GetModelResult;
};
const modelConfigDescriptions = {
@@ -43,39 +44,46 @@ const modelConfigDescriptions = {
"Regex pattern to match `model` parameter of generations to model pricing",
startDate:
"Date to start pricing model. If not set, model is active unless a more recent version exists.",
inputPrice: "Price per 1000 units of input",
outputPrice: "Price per 1000 units of output",
totalPrice:
"Price per 1000 units, for models that don't have input/output specific prices",
unit: "Unit of measurement for generative model, can be TOKENS, CHARACTERS, SECONDS, MILLISECONDS, REQUESTS or IMAGES.",
prices: "Prices per usage type",
tokenizerId:
"Tokenizer used for this model to calculate token counts if none are ingested. Pick from list of supported tokenizers.",
config:
"Some tokenizers require additional configuration (e.g. openai tiktoken). See docs for details.",
maintainer:
"Maintainer of the model. Langfuse managed models can be cloned, user managed models can be edited and deleted. To supersede a Langfuse managed model, set the custom model name to the Langfuse model name.",
} as const;
export default function ModelTable({ projectId }: { projectId: string }) {
const router = useRouter();
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
pageSize: withDefault(NumberParam, 50),
});
const models = api.models.all.useQuery({
page: paginationState.pageIndex,
limit: paginationState.pageSize,
projectId,
});
const models = api.models.getAll.useQuery(
{
page: paginationState.pageIndex,
limit: paginationState.pageSize,
projectId,
},
{
refetchOnWindowFocus: false,
refetchOnMount: true,
refetchOnReconnect: false,
staleTime: 1000 * 60 * 10,
},
);
const totalCount = models.data?.totalCount ?? null;
const { priceUnit } = usePriceUnitMultiplier();
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "m");
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "s");
// Set row height to medium if small as view is not optimized for small row heights
useEffect(() => {
if (rowHeight === "s") {
setRowHeight("m");
}
}, [rowHeight, setRowHeight]);
const columns: LangfuseColumnDef<ModelTableRow>[] = [
{
accessorKey: "maintainer",
id: "maintainer",
enableColumnFilter: true,
header: "Maintainer",
size: 100,
},
{
accessorKey: "modelName",
id: "modelName",
@@ -83,23 +91,40 @@ export default function ModelTable({ projectId }: { projectId: string }) {
headerTooltip: {
description: modelConfigDescriptions.modelName,
},
size: 150,
cell: ({ row }) => {
return (
<span className="font-mono text-xs font-semibold">
{row.original.modelName}
</span>
);
},
size: 120,
},
{
accessorKey: "startDate",
id: "startDate",
header: "Start Date",
accessorKey: "maintainer",
id: "maintainer",
header: "Maintainer",
headerTooltip: {
description: modelConfigDescriptions.startDate,
description: modelConfigDescriptions.maintainer,
},
size: 100,
size: 60,
cell: ({ row }) => {
const value: Date | undefined = row.getValue("startDate");
return value ? (
<span className="text-xs">{value.toISOString().slice(0, 10)} </span>
) : (
<span className="text-xs">-</span>
const isLangfuse = row.original.maintainer === "Langfuse";
return (
<div className="flex justify-center">
<Tooltip>
<TooltipTrigger>
{isLangfuse ? (
<LangfuseIcon size={16} />
) : (
<UserCircle2Icon className="h-4 w-4" />
)}
</TooltipTrigger>
<TooltipContent>
{isLangfuse ? "Langfuse maintained" : "User maintained"}
</TooltipContent>
</Tooltip>
</div>
);
},
},
@@ -115,104 +140,37 @@ export default function ModelTable({ projectId }: { projectId: string }) {
const value: string = row.getValue("matchPattern");
return value ? (
<IOTableCell data={value} singleLine={rowHeight === "s"} />
<span className="font-mono text-xs">{value}</span>
) : null;
},
},
{
accessorKey: "inputPrice",
id: "inputPrice",
accessorKey: "prices",
id: "prices",
header: () => {
return (
<>
Input Price{" "}
<span className="text-xs text-muted-foreground">/ 1k units</span>
</>
<div className="flex items-center gap-2">
<span>Prices {priceUnit}</span>
<PriceUnitSelector />
</div>
);
},
headerTooltip: {
description: modelConfigDescriptions.inputPrice,
},
size: 170,
size: 120,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("inputPrice");
const prices: Record<string, number> | undefined =
row.getValue("prices");
return value ? (
<span className="text-xs">
{usdFormatter(value.toNumber() * 1000, 2, 8)}
</span>
) : (
<span className="text-xs">-</span>
);
},
enableHiding: true,
},
{
accessorKey: "outputPrice",
id: "outputPrice",
headerTooltip: {
description: modelConfigDescriptions.outputPrice,
},
header: () => {
return (
<>
Output Price{" "}
<span className="text-xs text-muted-foreground">/ 1k units</span>
</>
);
},
size: 170,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("outputPrice");
return value ? (
<span className="text-xs">
{usdFormatter(value.toNumber() * 1000, 2, 8)}
</span>
) : (
<span className="text-xs">-</span>
<PriceBreakdownTooltip
modelName={row.original.modelName}
prices={prices}
priceUnit={priceUnit}
rowHeight={rowHeight}
/>
);
},
enableHiding: true,
},
{
accessorKey: "totalPrice",
id: "totalPrice",
header: () => {
return (
<>
Total Price{" "}
<span className="text-xs text-muted-foreground">/ 1k units</span>
</>
);
},
headerTooltip: {
description: modelConfigDescriptions.totalPrice,
},
size: 170,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("totalPrice");
return value ? (
<span className="text-xs">
{usdFormatter(value.toNumber() * 1000, 2, 8)}
</span>
) : (
<span className="text-xs">-</span>
);
},
enableHiding: true,
},
{
accessorKey: "unit",
id: "unit",
header: "Unit",
headerTooltip: {
description: modelConfigDescriptions.unit,
},
enableHiding: true,
size: 110,
},
{
accessorKey: "tokenizerId",
id: "tokenizerId",
@@ -221,7 +179,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
description: modelConfigDescriptions.tokenizerId,
},
enableHiding: true,
size: 110,
size: 120,
},
{
accessorKey: "config",
@@ -231,7 +189,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
description: modelConfigDescriptions.config,
},
enableHiding: true,
size: 200,
size: 120,
cell: ({ row }) => {
const value: Prisma.JsonValue | undefined = row.getValue("config");
@@ -243,14 +201,29 @@ export default function ModelTable({ projectId }: { projectId: string }) {
{
accessorKey: "actions",
header: "Actions",
size: 70,
size: 120,
cell: ({ row }) => {
return (
<DeleteModelButton
projectId={projectId}
modelId={row.original.modelId}
isBuiltIn={row.original.maintainer === "Langfuse"}
/>
return row.original.maintainer !== "Langfuse" ? (
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<EditModelButton
projectId={projectId}
modelData={row.original.serverResponse}
/>
<DeleteModelButton
projectId={projectId}
modelData={row.original.serverResponse}
/>
</div>
) : (
<div onClick={(e) => e.stopPropagation()}>
<CloneModelButton
projectId={projectId}
modelData={row.original.serverResponse}
/>
</div>
);
},
},
@@ -264,21 +237,16 @@ export default function ModelTable({ projectId }: { projectId: string }) {
columns,
);
const convertToTableRow = (model: Model): ModelTableRow => {
const convertToTableRow = (model: GetModelResult): ModelTableRow => {
return {
modelId: model.id,
maintainer: model.projectId ? "User" : "Langfuse",
modelName: model.modelName,
matchPattern: model.matchPattern,
startDate: model.startDate ? new Date(model.startDate) : undefined,
inputPrice: model.inputPrice ? new Decimal(model.inputPrice) : undefined,
outputPrice: model.outputPrice
? new Decimal(model.outputPrice)
: undefined,
totalPrice: model.totalPrice ? new Decimal(model.totalPrice) : undefined,
unit: model.unit ?? "",
prices: model.prices,
tokenizerId: model.tokenizerId ?? undefined,
config: model.tokenizerConfig,
serverResponse: model,
};
};
@@ -320,76 +288,10 @@ export default function ModelTable({ projectId }: { projectId: string }) {
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
onRowClick={(row) => {
router.push(`/project/${projectId}/models/${row.modelId}`);
}}
/>
</>
);
}
const DeleteModelButton = ({
modelId,
projectId,
isBuiltIn,
}: {
modelId: string;
projectId: string;
isBuiltIn?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const capture = usePostHogClientCapture();
const mut = api.models.delete.useMutation({
onSuccess: () => {
void utils.models.invalidate();
},
});
const hasAccess = useHasProjectAccess({
projectId,
scope: "models:CUD",
});
return (
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="xs"
disabled={!hasAccess || isBuiltIn}
title={
isBuiltIn ? "Built-in models cannot be deleted" : "Delete model"
}
className={cn(
isBuiltIn &&
"disabled:pointer-events-auto disabled:cursor-not-allowed",
)}
>
<Trash className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent>
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
<p className="mb-3 text-sm">
This action permanently deletes this model definition.
</p>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="destructive"
loading={mut.isLoading}
onClick={() => {
capture("models:delete_button_click");
mut.mutateAsync({
projectId,
modelId,
});
setIsOpen(false);
}}
>
Delete Model
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -6,6 +6,7 @@ import {
} from "@/src/components/ui/tooltip";
import { useState } from "react";
import Decimal from "decimal.js";
import { getMaxDecimals } from "@/src/features/models/utils";
interface Details {
[key: string]: number | undefined;
@@ -36,15 +37,6 @@ export const BreakdownTooltip = ({
}, {})
: details;
// For costs, calculate the maximum number of decimal places needed
const getMaxDecimals = (value: number | undefined): number => {
if (!value) return 0;
const parts = value.toString().split(".");
// If no decimal point, return 0, else return length of decimal part
return parts.length === 1 ? 0 : parts[1].length;
};
const formatValueWithPadding = (value: number, maxDecimals: number) => {
return !value
? "0"
@@ -39,7 +39,8 @@ import {
} from "@/src/components/ui/tabs-bar";
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
import { BreakdownTooltip } from "./BreakdownToolTip";
import { InfoIcon } from "lucide-react";
import { InfoIcon, PlusCircle } from "lucide-react";
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
export const ObservationPreview = ({
observations,
@@ -210,7 +211,47 @@ export const ObservationPreview = ({
</Badge>
) : undefined}
{preloadedObservation.model ? (
<Badge variant="outline">{preloadedObservation.model}</Badge>
preloadedObservation.modelId ? (
<Badge>
<Link
href={`/project/${preloadedObservation.projectId}/models/${preloadedObservation.modelId}`}
className="flex items-center"
title="View model details"
>
{preloadedObservation.model}
</Link>
</Badge>
) : (
<UpsertModelFormDrawer
action="create"
projectId={preloadedObservation.projectId}
prefilledModelData={{
modelName: preloadedObservation.model,
prices:
Object.keys(preloadedObservation.usageDetails)
.length > 0
? Object.keys(preloadedObservation.usageDetails)
.filter((key) => key != "total")
.reduce(
(acc, key) => {
acc[key] = 0.000001;
return acc;
},
{} as Record<string, number>,
)
: undefined,
}}
className="cursor-pointer"
>
<Badge
variant="outline"
className="flex items-center gap-1"
>
<span>{preloadedObservation.model}</span>
<PlusCircle className="h-3 w-3" />
</Badge>
</UpsertModelFormDrawer>
)
) : null}
{thisCost ? (
<BreakdownTooltip
+119 -19
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
/**
* useLocalStorage is a hook for managing data with the localStorage API.
* It provides cross-tab synchronization and safe interaction with localStorage.
*
* @param {string} localStorageKey - The key under which the value is stored in localStorage.
* @param {T} initialValue - The initial value of the data to be stored.
@@ -10,49 +11,148 @@ import { useState, useEffect } from "react";
*
* @return An array with three elements:
* value: Current value
* setValue: Function to update the value
* setValue: Function to update the value and sync across tabs
* clearValue: Function to remove value from the local storage.
* This function will also reset the value to initial value
*
* @template T - The type of the data to be stored in localStorage. It should be a type that can be stringified.
*
* @throws Will throw an error if the stringifying the value or accessing local storage fails.
*
* @example
* const [theme, setTheme, clearTheme] = useLocalStorage('theme', 'light');
* // Use theme value
* // Call setTheme to update
* // Call clearTheme to reset to 'light'
*/
function useLocalStorage<T>(
localStorageKey: string,
initialValue: T,
): [T, React.Dispatch<React.SetStateAction<T>>, () => void] {
// Initialize state with value from localStorage or initial value
// This initialization is only run once when the component mounts
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue;
}
// Return initial value if running on server-side
if (typeof window === "undefined") return initialValue;
try {
const storedValue = localStorage.getItem(localStorageKey);
return storedValue ? (JSON.parse(storedValue) as T) : initialValue;
const stored = localStorage.getItem(localStorageKey);
// Parse stored value if it exists, otherwise use initial value
return stored ? (JSON.parse(stored) as T) : initialValue;
} catch (error) {
console.error("Error reading from local storage", error);
return initialValue;
}
});
const clearValue = () => {
try {
localStorage.removeItem(localStorageKey);
setValue(initialValue);
} catch (error) {
console.error("Error clearing local storage", error);
}
// Helper object to safely interact with localStorage
// Handles all error cases and provides consistent interface
const safeLocalStorage = {
set: (value: T) => {
try {
const stringified = JSON.stringify(value);
localStorage.setItem(localStorageKey, stringified);
return stringified;
} catch (error) {
console.error("Error writing to local storage", error);
return null;
}
},
remove: () => {
try {
localStorage.removeItem(localStorageKey);
} catch (error) {
console.error("Error clearing local storage", error);
}
},
};
// Function to clear both localStorage and state
const clearValue = () => {
safeLocalStorage.remove();
setValue(initialValue);
};
// Sync to localStorage whenever value changes
// This ensures localStorage always has the latest value
useEffect(() => {
try {
localStorage.setItem(localStorageKey, JSON.stringify(value));
} catch (error) {
console.error("Error writing to local storage", error);
}
safeLocalStorage.set(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localStorageKey, value]);
return [value, setValue, clearValue] as const;
// Handle cross-tab synchronization
useEffect(() => {
if (typeof window === "undefined") return;
// Handler for native localStorage events (triggered by other tabs)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === localStorageKey) {
try {
setValue(e.newValue ? (JSON.parse(e.newValue) as T) : initialValue);
} catch (error) {
console.error("Error parsing storage change", error);
}
}
};
// Handler for custom events (triggered within same tab)
const handleCustomEvent = (
e: CustomEvent<{ key: string; newValue: string }>,
) => {
if (e.detail.key === localStorageKey) {
try {
setValue(
e.detail.newValue
? (JSON.parse(e.detail.newValue) as T)
: initialValue,
);
} catch (error) {
console.error("Error parsing custom event", error);
}
}
};
// Listen for both storage events and custom events
window.addEventListener("storage", handleStorageChange);
window.addEventListener(
"localStorageChange",
handleCustomEvent as EventListener,
);
// Cleanup listeners on unmount
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener(
"localStorageChange",
handleCustomEvent as EventListener,
);
};
}, [localStorageKey, initialValue]);
// Enhanced setValue function that also notifies other tabs
const setValueAndNotify: React.Dispatch<React.SetStateAction<T>> = (
newValue,
) => {
setValue((prev) => {
// Handle both direct values and updater functions
const resolvedValue =
newValue instanceof Function ? newValue(prev) : newValue;
const stringified = safeLocalStorage.set(resolvedValue);
// Dispatch custom event to notify other instances in the same tab
if (stringified) {
window.dispatchEvent(
new CustomEvent("localStorageChange", {
detail: { key: localStorageKey, newValue: stringified },
}),
);
}
return resolvedValue;
});
};
return [value, setValueAndNotify, clearValue] as const;
}
export default useLocalStorage;
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.3.0";
export const VERSION = "v3.4.0";
@@ -31,7 +31,6 @@ import {
import { api } from "@/src/utils/api";
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
import { CommandItem } from "@/src/components/ui/command";
import { useRouter } from "next/router";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import {
@@ -39,6 +38,7 @@ import {
useEntitlementLimit,
} from "@/src/features/entitlements/hooks";
import { ActionButton } from "@/src/components/ActionButton";
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
export const CreateOrEditAnnotationQueueButton = ({
projectId,
@@ -257,7 +257,7 @@ export const CreateOrEditAnnotationQueueButton = ({
};
})}
controlButtons={
<CommandItem
<DropdownMenuItem
onSelect={() => {
capture(
"score_configs:manage_configs_item_click",
@@ -269,7 +269,7 @@ export const CreateOrEditAnnotationQueueButton = ({
}}
>
Manage score configs
</CommandItem>
</DropdownMenuItem>
}
/>
</FormControl>
@@ -77,6 +77,7 @@ import {
} from "@/src/components/ui/dialog";
import Link from "next/link";
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
const CreateExperimentData = z.object({
name: z
@@ -790,13 +791,13 @@ export const CreateExperimentsForm = ({
}
hideClearButton
controlButtons={
<CommandItem
<DropdownMenuItem
onSelect={() => {
window.open(`/project/${projectId}/evals`, "_blank");
}}
>
Manage evaluators
</CommandItem>
</DropdownMenuItem>
}
/>
</FormItem>
+13
View File
@@ -95,6 +95,17 @@ export const env = createEnv({
AUTH_CUSTOM_ISSUER: z.string().url().optional(),
AUTH_CUSTOM_NAME: z.string().optional(),
AUTH_CUSTOM_SCOPE: z.string().optional(),
AUTH_CUSTOM_CLIENT_AUTH_METHOD: z
.enum([
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"tls_client_auth",
"self_signed_tls_client_auth",
"none",
])
.optional(),
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
AUTH_IGNORE_ACCOUNT_FIELDS: z.string().optional(),
@@ -367,6 +378,8 @@ export const env = createEnv({
AUTH_CUSTOM_ISSUER: process.env.AUTH_CUSTOM_ISSUER,
AUTH_CUSTOM_NAME: process.env.AUTH_CUSTOM_NAME,
AUTH_CUSTOM_SCOPE: process.env.AUTH_CUSTOM_SCOPE,
AUTH_CUSTOM_CLIENT_AUTH_METHOD:
process.env.AUTH_CUSTOM_CLIENT_AUTH_METHOD,
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING,
AUTH_IGNORE_ACCOUNT_FIELDS:
@@ -1,31 +1,19 @@
import { api } from "@/src/utils/api";
import {
type ScoreSource,
type FilterState,
type ScoreDataType,
} from "@langfuse/shared";
import { type FilterState } from "@langfuse/shared";
import { createTracesTimeFilter } from "@/src/features/dashboard/lib/dashboard-utils";
import {
type DashboardDateRangeAggregationOption,
dashboardDateRangeAggregationSettings,
} from "@/src/utils/date-range-utils";
import React, { useMemo } from "react";
import { BarChart } from "@tremor/react";
import { Card } from "@/src/components/ui/card";
import { getColorsForCategories } from "@/src/features/dashboard/utils/getColorsForCategories";
import {
isEmptyBarChart,
transformCategoricalScoresToChartData,
} from "@/src/features/dashboard/lib/score-analytics-utils";
import { NoDataOrLoading } from "@/src/components/NoDataOrLoading";
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
import { DashboardCategoricalScoreAdapter } from "@/src/features/scores/adapters";
import { type ScoreData } from "@/src/features/scores/types";
import { CategoricalChart } from "@/src/features/scores/components/ScoreChart";
export function CategoricalScoreChart(props: {
projectId: string;
name: string;
source: ScoreSource;
dataType: ScoreDataType;
scoreData: ScoreData;
globalFilterState: FilterState;
agg?: DashboardDateRangeAggregationOption;
}) {
@@ -45,19 +33,19 @@ export function CategoricalScoreChart(props: {
{
type: "string",
column: "scoreName",
value: props.name,
value: props.scoreData.name,
operator: "=",
},
{
type: "string",
column: "scoreSource",
value: props.source,
value: props.scoreData.source,
operator: "=",
},
{
type: "string",
column: "scoreDataType",
value: props.dataType,
value: props.scoreData.dataType,
operator: "=",
},
],
@@ -99,40 +87,23 @@ export function CategoricalScoreChart(props: {
);
const { chartData, chartLabels } = useMemo(() => {
return scores.data
? transformCategoricalScoresToChartData(
scores.data,
"scoreTimestamp",
props.agg,
)
: { chartData: [], chartLabels: [] };
if (!scores.data) return { chartData: [], chartLabels: [] };
const adapter = new DashboardCategoricalScoreAdapter(
scores.data,
"scoreTimestamp",
props.agg,
);
return adapter.toChartData();
}, [scores.data, props.agg]);
const barCategoryGap = (chartLength: number): string => {
if (chartLength > 7) return "10%";
if (chartLength > 5) return "20%";
if (chartLength > 3) return "30%";
else return "40%";
};
const colors = getColorsForCategories(chartLabels);
return isEmptyBarChart({ data: chartData }) ? (
<NoDataOrLoading isLoading={scores.isLoading} />
) : (
<Card className="min-h-[9rem] w-full flex-1 rounded-tremor-default border">
<BarChart
className="mt-4"
data={chartData}
index="binLabel"
categories={chartLabels}
colors={colors}
valueFormatter={(number: number) =>
Intl.NumberFormat("en-US").format(number).toString()
}
yAxisWidth={48}
barCategoryGap={barCategoryGap(chartData.length)}
stack={!!props.agg}
/>
</Card>
return (
<CategoricalChart
chartData={chartData}
chartLabels={chartLabels}
isLoading={scores.isLoading}
className="min-h-[9rem] flex-1"
stack={!!props.agg}
/>
);
}
@@ -127,19 +127,17 @@ export function ScoreAnalytics(props: {
{(isCategoricalDataType(dataType) ||
isBooleanDataType(dataType)) && (
<CategoricalScoreChart
source={source}
name={name}
dataType={dataType}
projectId={props.projectId}
scoreData={scoreData}
globalFilterState={props.globalFilterState}
/>
)}
{isNumericDataType(dataType) && (
<NumericScoreHistogram
projectId={props.projectId}
source={source}
name={name}
dataType={dataType}
projectId={props.projectId}
globalFilterState={props.globalFilterState}
/>
)}
@@ -154,11 +152,9 @@ export function ScoreAnalytics(props: {
{(isCategoricalDataType(dataType) ||
isBooleanDataType(dataType)) && (
<CategoricalScoreChart
agg={props.agg}
source={source}
name={name}
dataType={dataType}
projectId={props.projectId}
agg={props.agg}
scoreData={scoreData}
globalFilterState={props.globalFilterState}
/>
)}
@@ -1,16 +1,36 @@
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
import { type DatabaseRow } from "@/src/server/api/services/queryBuilder";
import {
type CategoryCounts,
type ChartBin,
type HistogramBin,
} from "@/src/features/scores/types";
import { type RouterOutputs } from "@/src/utils/api";
// types
type HistogramBin = { binLabel: string; count: number };
type CategoryCounts = Record<string, number>;
type ChartBin = { binLabel: string } & CategoryCounts;
export const RESOURCE_METRICS = [
{
key: "latency",
value: "Latency",
objectKey: "avgLatency",
label: "Latency (ms)",
},
{
key: "cost",
value: "Cost",
objectKey: "avgTotalCost",
label: "Average Total Cost ($)",
},
];
// numeric score analytics helpers
function round(value: number, precision = 2) {
return parseFloat(value.toFixed(precision));
}
export function uniqueAndSort(labels: string[]): string[] {
return Array.from(new Set(labels)).sort();
}
function computeBinSize(
minBins: number,
maxBins: number,
@@ -125,8 +145,112 @@ function groupCategoricalScoreDataByTimestamp(
);
}
function uniqueAndSort(labels: string[]): string[] {
return Array.from(new Set(labels)).sort();
type ChartAccumulator = Map<
string,
{ chartData: ChartBin[]; chartLabels: string[] }
>;
function initializeOrGetChartData(acc: ChartAccumulator, key: string) {
if (!acc.has(key)) {
acc.set(key, { chartData: [], chartLabels: [] });
}
return acc.get(key)!;
}
function createNumericScoreData(run: string, score: number, scoreName: string) {
return {
chartLabels: [scoreName],
chartBin: {
binLabel: run,
[scoreName]: score,
} as ChartBin,
};
}
function createCategoricalScoreData(
run: string,
valueCounts: Array<{ value: string; count: number }>,
values: string[],
) {
const categoryCounts = valueCounts.reduce(
(counts, { value, count }) => ({
...counts,
[value]: count,
}),
{} as CategoryCounts,
);
return {
chartLabels: values,
chartBin: {
binLabel: run,
...categoryCounts,
} as ChartBin,
};
}
function addMetricToAccumulator(
acc: ChartAccumulator,
key: string,
chartBin: ChartBin,
chartLabels: string[],
) {
const current = initializeOrGetChartData(acc, key);
acc.set(key, {
chartData: [...current.chartData, chartBin],
chartLabels,
});
}
export function transformAggregatedRunMetricsToChartData(
runMetrics: RouterOutputs["datasets"]["runsByDatasetIdMetrics"]["runs"],
scoreIdToName: Map<string, string>,
) {
const reversedMetrics = runMetrics.slice().reverse();
return reversedMetrics.reduce((acc, run) => {
// Handle scores
Object.entries(run.scores ?? {}).forEach(([scoreId, score]) => {
const scoreData =
score.type === "NUMERIC"
? createNumericScoreData(
run.name,
score.average,
scoreIdToName.get(scoreId) ?? scoreId,
)
: createCategoricalScoreData(
run.name,
score.valueCounts,
score.values,
);
addMetricToAccumulator(
acc,
scoreId,
scoreData.chartBin,
scoreData.chartLabels,
);
});
// Handle resource metrics
RESOURCE_METRICS.forEach(({ key, objectKey }) => {
const resourceValue = run[objectKey as keyof typeof run];
const resourceData = createNumericScoreData(
run.name,
!!resourceValue ? Number(resourceValue) : 0,
key,
);
addMetricToAccumulator(
acc,
key,
resourceData.chartBin,
resourceData.chartLabels,
);
});
return acc;
}, new Map());
}
export function transformCategoricalScoresToChartData(
@@ -155,11 +279,11 @@ export function transformCategoricalScoresToChartData(
chartData.push({ ...categoryCounts, binLabel: timestamp } as ChartBin);
});
return { chartData, chartLabels: uniqueAndSort(chartLabels) };
return { chartData, chartLabels };
}
}
export function isEmptyBarChart({ data }: { data: ChartBin[] }) {
export function isEmptyChart({ data }: { data: ChartBin[] }) {
return (
data.length === 0 || data.every((item) => Object.keys(item).length === 1)
);
@@ -0,0 +1,36 @@
import { RESOURCE_METRICS } from "@/src/features/dashboard/lib/score-analytics-utils";
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
import { ChartColumnBig } from "lucide-react";
export function DatasetAnalytics(props: {
projectId: string;
scoreOptions: { key: string; value: string }[];
selectedMetrics: string[];
setSelectedMetrics: (metrics: string[]) => void;
}) {
return (
<MultiSelectKeyValues
className="max-w-fit"
placeholder="Search..."
title="Charts"
iconLeft={<ChartColumnBig className="mr-1 h-4 w-4" />}
hideClearButton
onValueChange={(values, changedValue, selectedKeys) => {
if (values.length === 0) props.setSelectedMetrics([]);
if (changedValue) {
if (selectedKeys?.has(changedValue)) {
props.setSelectedMetrics([...props.selectedMetrics, changedValue]);
} else {
props.setSelectedMetrics(
props.selectedMetrics.filter((key) => key !== changedValue),
);
}
}
}}
values={props.selectedMetrics}
options={RESOURCE_METRICS}
groupedOptions={[{ label: "Scores", options: props.scoreOptions }]}
/>
);
}
@@ -6,7 +6,7 @@ import { api } from "@/src/utils/api";
import { formatIntervalSeconds } from "@/src/utils/dates";
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import { type RouterOutput } from "@/src/utils/types";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { usdFormatter } from "../../../utils/numbers";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
@@ -14,6 +14,7 @@ import { type Prisma } from "@langfuse/shared";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import {
getScoreDataTypeIcon,
getScoreGroupColumnProps,
verifyAndPrefixScoreDataAgainstKeys,
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
@@ -36,6 +37,14 @@ import Link from "next/link";
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
import { Skeleton } from "@/src/components/ui/skeleton";
import {
RESOURCE_METRICS,
transformAggregatedRunMetricsToChartData,
} from "@/src/features/dashboard/lib/score-analytics-utils";
import { TimeseriesChart } from "@/src/features/scores/components/TimeseriesChart";
import { Card, CardContent } from "@/src/components/ui/card";
import { CompareViewAdapter } from "@/src/features/scores/adapters";
import { isNumericDataType } from "@/src/features/scores/lib/helpers";
export type DatasetRunRowData = {
id: string;
@@ -93,6 +102,8 @@ const DatasetRunTableMultiSelectAction = ({
export function DatasetRunsTable(props: {
projectId: string;
datasetId: string;
selectedMetrics: string[];
setScoreOptions: (options: { key: string; value: string }[]) => void;
menuItems?: React.ReactNode;
}) {
const [paginationState, setPaginationState] = useQueryParams({
@@ -105,6 +116,8 @@ export function DatasetRunsTable(props: {
"datasetRuns",
"s",
);
const { setScoreOptions } = props;
const runs = api.datasets.runsByDatasetId.useQuery({
projectId: props.projectId,
datasetId: props.datasetId,
@@ -149,6 +162,37 @@ export function DatasetRunsTable(props: {
showAggregateViewOnly: true,
});
const scoreIdToName = useMemo(() => {
return new Map(scoreKeysAndProps.map((obj) => [obj.key, obj.name]) ?? []);
}, [scoreKeysAndProps]);
const runAggregatedMetrics = useMemo(() => {
return transformAggregatedRunMetricsToChartData(
runsMetrics.data?.runs ?? [],
scoreIdToName,
);
}, [runsMetrics.data, scoreIdToName]);
const { scoreAnalyticsOptions, scoreKeyToData } = useMemo(() => {
const scoreAnalyticsOptions = scoreKeysAndProps
? scoreKeysAndProps.map(({ key, name, dataType, source }) => ({
key,
value: `${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`,
}))
: [];
return {
scoreAnalyticsOptions,
scoreKeyToData: new Map(
scoreKeysAndProps.map((obj) => [obj.key, obj]) ?? [],
),
};
}, [scoreKeysAndProps]);
useEffect(() => {
setScoreOptions(scoreAnalyticsOptions);
}, [scoreAnalyticsOptions, setScoreOptions]);
const columns: LangfuseColumnDef<DatasetRunRowData>[] = [
{
id: "select",
@@ -348,6 +392,51 @@ export function DatasetRunsTable(props: {
return (
<>
{Boolean(props.selectedMetrics.length) &&
Boolean(runAggregatedMetrics?.size) && (
<Card className="my-4 max-h-[25dvh] md:max-h-[30dvh]">
<CardContent className="mt-2 h-full">
<div className="flex h-full w-full gap-4 overflow-x-auto">
{props.selectedMetrics.map((key) => {
const adapter = new CompareViewAdapter(
runAggregatedMetrics,
key,
);
const { chartData, chartLabels } = adapter.toChartData();
const scoreData = scoreKeyToData.get(key);
if (!scoreData)
return (
<TimeseriesChart
key={key}
chartData={chartData}
chartLabels={chartLabels}
title={
RESOURCE_METRICS.find((metric) => metric.key === key)
?.label ?? key
}
type="numeric"
/>
);
return (
<TimeseriesChart
key={key}
chartData={chartData}
chartLabels={chartLabels}
title={`${getScoreDataTypeIcon(scoreData.dataType)} ${scoreData.name} (${scoreData.source.toLowerCase()})`}
type={
isNumericDataType(scoreData.dataType)
? "numeric"
: "categorical"
}
/>
);
})}
</div>
</CardContent>
</Card>
)}
<DataTableToolbar
columns={columns}
columnVisibility={columnVisibility}
@@ -858,8 +858,9 @@ async function runsByDatasetIdPg(
projectId: string;
datasetId: string;
queryClickhouse: boolean;
page: number;
limit: number;
page?: number;
limit?: number;
runIds?: string[];
},
) {
const scoresByRunId = await prisma.$queryRaw<
@@ -889,10 +890,11 @@ async function runsByDatasetIdPg(
runs.dataset_id = ${input.datasetId}
AND runs.project_id = ${input.projectId}
AND s.score IS NOT NULL
${input.runIds ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
GROUP BY
runs.id
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
`);
const runs = await prisma.$queryRaw<
@@ -983,10 +985,11 @@ async function runsByDatasetIdPg(
WHERE
runs.dataset_id = ${input.datasetId}
AND runs.project_id = ${input.projectId}
${input.runIds ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
ORDER BY
runs.created_at DESC
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
`);
const totalRuns = await prisma.datasetRuns.count({
@@ -1002,7 +1005,7 @@ async function runsByDatasetIdPg(
...run,
scores: aggregateScores(
scoresByRunId.flatMap((s) => (s.runId === run.id ? s.scores : [])),
) as ScoreAggregate | undefined
) as ScoreAggregate | undefined,
})),
};
}
+6 -4
View File
@@ -1,9 +1,9 @@
import {
filterAndValidateDbScoreList,
paginationZod,
Prisma,
type PrismaClient,
type DatasetRunItems,
optionalPaginationZod,
} from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { v4 } from "uuid";
@@ -32,7 +32,8 @@ export const datasetRunsTableSchema = z.object({
projectId: z.string(),
datasetId: z.string(),
queryClickhouse: z.boolean().optional().default(false),
...paginationZod,
runIds: z.array(z.string()).optional(),
...optionalPaginationZod,
});
type PostgresRunItem = {
@@ -235,10 +236,11 @@ export const getDatasetRunsFromPostgres = async (
WHERE
d.id = ${input.datasetId}
AND d.project_id = ${input.projectId}
${input.runIds?.length ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
GROUP BY runs.id, runs.name, runs.description, runs.metadata, runs.created_at, runs.updated_at
ORDER BY runs.created_at DESC
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
`,
);
};
@@ -0,0 +1,30 @@
import { Button } from "@/src/components/ui/button";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
import { type GetModelResult } from "@/src/features/models/validation";
export const CloneModelButton = ({
modelData,
projectId,
}: {
modelData: GetModelResult;
projectId: string;
}) => {
const hasAccess = useHasProjectAccess({
projectId,
scope: "models:CUD",
});
return (
<UpsertModelFormDrawer {...{ modelData, projectId, action: "clone" }}>
<Button
variant="outline"
disabled={!hasAccess}
title="Clone model"
className="flex items-center"
>
<span>Clone</span>
</Button>
</UpsertModelFormDrawer>
);
};
@@ -0,0 +1,76 @@
import { useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { type GetModelResult } from "@/src/features/models/validation";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { api } from "@/src/utils/api";
export const DeleteModelButton = ({
modelData,
projectId,
onSuccess,
}: {
modelData: GetModelResult;
projectId: string;
onSuccess?: () => void;
}) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const capture = usePostHogClientCapture();
const mut = api.models.delete.useMutation({
onSuccess: () => {
void utils.models.invalidate();
onSuccess?.();
},
});
const hasAccess = useHasProjectAccess({
projectId,
scope: "models:CUD",
});
return (
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
<PopoverTrigger asChild>
<Button
variant="outline"
title="Delete model"
disabled={!hasAccess}
className="flex items-center border-light-red"
>
<span className="text-dark-red">Delete</span>
</Button>
</PopoverTrigger>
<PopoverContent>
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
<p className="mb-3 text-sm">
This action permanently deletes this model definition.
</p>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="destructive"
loading={mut.isLoading}
onClick={() => {
capture("models:delete_button_click");
mut.mutateAsync({
projectId,
modelId: modelData.id,
});
setIsOpen(false);
}}
>
Delete Model
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,30 @@
import { Button } from "@/src/components/ui/button";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
import { type GetModelResult } from "@/src/features/models/validation";
export const EditModelButton = ({
modelData,
projectId,
}: {
modelData: GetModelResult;
projectId: string;
}) => {
const hasAccess = useHasProjectAccess({
projectId,
scope: "models:CUD",
});
return (
<UpsertModelFormDrawer {...{ modelData, projectId, action: "edit" }}>
<Button
variant="outline"
disabled={!hasAccess}
title="Edit model"
className="flex items-center"
>
<span>Edit</span>
</Button>
</UpsertModelFormDrawer>
);
};
@@ -1,456 +0,0 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { DatePicker } from "@/src/components/date-picker";
import Header from "@/src/components/layouts/header";
import { Button } from "@/src/components/ui/button";
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 { ModelUsageUnit } from "@langfuse/shared";
import { AutoComplete } from "@/src/features/prompts/components/auto-complete";
import { api } from "@/src/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { JsonEditor } from "@/src/components/json-editor";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import Link from "next/link";
import { utcDate } from "@/src/utils/dates";
const formSchema = z.object({
modelName: z.string().min(1),
matchPattern: z.string(),
startDate: z.date().optional(),
inputPrice: z
.string()
.refine((value) => value === "" || isFinite(parseFloat(value)), {
message: "Price needs to be numeric",
})
.optional(),
outputPrice: z
.string()
.refine((value) => value === "" || isFinite(parseFloat(value)), {
message: "Price needs to be numeric",
})
.optional(),
totalPrice: z
.string()
.refine((value) => value === "" || isFinite(parseFloat(value)), {
message: "Price needs to be numeric",
})
.optional(),
unit: z.nativeEnum(ModelUsageUnit),
tokenizerId: z.enum(["openai", "claude", "None"]),
tokenizerConfig: z.string().refine(
(value) => {
try {
JSON.parse(value);
return true;
} catch (e) {
return false;
}
},
{
message: "Tokenizer config needs to be valid JSON",
},
),
});
export const NewModelForm = (props: {
projectId: string;
onFormSuccess?: () => void;
}) => {
const [formError, setFormError] = useState<string | null>(null);
const capture = usePostHogClientCapture();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
modelName: "",
matchPattern: "",
startDate: undefined,
inputPrice: "",
outputPrice: "",
totalPrice: "",
unit: ModelUsageUnit.Tokens,
tokenizerId: "None",
tokenizerConfig: "{}",
},
});
const utils = api.useUtils();
const createModelMutation = api.models.create.useMutation({
onSuccess: () => utils.models.invalidate(),
onError: (error) => setFormError(error.message),
});
const modelNames = api.models.modelNames.useQuery({
projectId: props.projectId,
});
function onSubmit(values: z.infer<typeof formSchema>) {
capture("models:new_form_submit");
createModelMutation
.mutateAsync({
projectId: props.projectId,
modelName: values.modelName,
matchPattern: values.matchPattern,
inputPrice: !!values.inputPrice
? parseFloat(values.inputPrice)
: undefined,
outputPrice: !!values.outputPrice
? parseFloat(values.outputPrice)
: undefined,
totalPrice: !!values.totalPrice
? parseFloat(values.totalPrice)
: undefined,
unit: values.unit,
tokenizerId:
values.tokenizerId === "None" ? undefined : values.tokenizerId,
tokenizerConfig:
values.tokenizerConfig &&
typeof JSON.parse(values.tokenizerConfig) === "object"
? (JSON.parse(values.tokenizerConfig) as Record<string, number>)
: undefined,
startDate: values.startDate ? utcDate(values.startDate) : undefined,
})
.then(() => {
props.onFormSuccess?.();
form.reset();
})
.catch((error) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if ("message" in error && typeof error.message === "string") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
setFormError(error.message as string);
return;
} else {
setFormError(JSON.stringify(error));
console.error(error);
}
});
}
return (
<Form {...form}>
<form
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-2"
>
<Header level="h3" title="Name" />
<FormField
control={form.control}
name="modelName"
render={({ field }) => (
<FormItem>
<FormLabel>Model Name</FormLabel>
<FormControl>
<AutoComplete
{...field}
options={
modelNames.data?.map((model) => ({
value: model,
label: model,
})) ?? []
}
placeholder=""
onValueChange={(option) => field.onChange(option.value)}
value={{ value: field.value, label: field.value }}
disabled={false}
createLabel="Create a new model name"
/>
</FormControl>
<FormDescription>
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.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Header level="h3" title="Scope" className="mt-3" />
<FormField
control={form.control}
name="matchPattern"
render={({ field }) => (
<FormItem>
<FormLabel>Match pattern</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
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$
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Start date (UTC)</FormLabel>
<FormControl>
<DatePicker
date={field.value}
onChange={(date) => field.onChange(date)}
clearable
/>
</FormControl>
<FormDescription>
If set, the model will only be used for generations after this
date.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Header level="h3" title="Pricing" className="mt-3" />
<FormField
control={form.control}
name="unit"
render={({ field }) => (
<FormItem>
<FormLabel>Unit</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a unit" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.values(ModelUsageUnit).map((unit) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
The unit of measurement for the model.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-3 gap-2">
<FormField
control={form.control}
name="inputPrice"
render={({ field }) => (
<FormItem>
<FormLabel>
Input price (USD per{" "}
{form.getValues("unit").toLowerCase().replace(/s$/, "")})
</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
{field.value !== null && field.value !== "" ? (
<FormDescription>
<ul className="font-mono text-xs">
<li>
{(parseFloat(field.value ?? "0") * 1000).toFixed(8)} USD
/ 1k {form.getValues("unit").toLowerCase()}
</li>
<li>
{(parseFloat(field.value ?? "0") * 100_000).toFixed(8)}{" "}
USD / 100k {form.getValues("unit").toLowerCase()}
</li>
<li>
{(parseFloat(field.value ?? "0") * 1_000_000).toFixed(
8,
)}{" "}
USD / 1M {form.getValues("unit").toLowerCase()}
</li>
</ul>
</FormDescription>
) : null}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="outputPrice"
render={({ field }) => (
<FormItem>
<FormLabel>
Output price (USD per{" "}
{form.getValues("unit").toLowerCase().replace(/s$/, "")})
</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
{field.value !== null && field.value !== "" ? (
<FormDescription>
<ul className="font-mono text-xs">
<li>
{(parseFloat(field.value ?? "0") * 1000).toFixed(8)} USD
/ 1k {form.getValues("unit").toLowerCase()}
</li>
<li>
{(parseFloat(field.value ?? "0") * 100_000).toFixed(8)}{" "}
USD / 100k {form.getValues("unit").toLowerCase()}
</li>
<li>
{(parseFloat(field.value ?? "0") * 1_000_000).toFixed(
8,
)}{" "}
USD / 1M {form.getValues("unit").toLowerCase()}
</li>
</ul>
</FormDescription>
) : null}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="totalPrice"
render={({ field }) => (
<FormItem>
<FormLabel>
Total price (USD per{" "}
{form.getValues("unit").toLowerCase().replace(/s$/, "")})
</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
<FormDescription>
{field.value !== null && field.value !== "" ? (
<ul className="mt-2 font-mono text-xs">
<li>
{(parseFloat(field.value ?? "0") * 1000).toFixed(8)} USD
/ 1k {form.getValues("unit").toLowerCase()}
</li>
<li>
{(parseFloat(field.value ?? "0") * 100_000).toFixed(8)}{" "}
USD / 100k {form.getValues("unit").toLowerCase()}
</li>
<li>
{(parseFloat(field.value ?? "0") * 1_000_000).toFixed(
8,
)}{" "}
USD / 1M {form.getValues("unit").toLowerCase()}
</li>
</ul>
) : (
"Enter total price only if no separate input and output prices are provided."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Header level="h3" title="Tokenization" className="mt-3" />
<FormField
control={form.control}
name="tokenizerId"
render={({ field }) => (
<FormItem>
<FormLabel>Tokenizer</FormLabel>
<Select
onValueChange={(tokenizerId) => {
field.onChange(tokenizerId);
if (tokenizerId === "None") {
form.setValue("tokenizerConfig", "{}");
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a unit" />
</SelectTrigger>
</FormControl>
<SelectContent>
{["openai", "claude", "None"].map((unit) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
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{" "}
<Link
href="https://langfuse.com/docs/model-usage-and-cost"
className="underline"
target="_blank"
>
docs
</Link>
.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("tokenizerId") !== "None" && (
<FormField
control={form.control}
name="tokenizerConfig"
render={({ field }) => (
<FormItem>
<FormLabel>Tokenizer Config</FormLabel>
<JsonEditor
defaultValue={field.value}
onChange={field.onChange}
/>
<FormDescription>
The config for the tokenizer. Required for openai. See the{" "}
<Link
href="https://langfuse.com/docs/model-usage-and-cost"
className="underline"
target="_blank"
>
docs
</Link>{" "}
for details.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<Button
type="submit"
loading={createModelMutation.isLoading}
className="mt-6"
>
Save
</Button>
</form>
{formError ? (
<p className="text-red text-center">
<span className="font-bold">Error:</span> {formError}
</p>
) : null}
</Form>
);
};
@@ -0,0 +1,114 @@
import Decimal from "decimal.js";
import { InfoIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { type RowHeight } from "@/src/components/table/data-table-row-height-switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/src/components/ui/tooltip";
import { usePriceUnitMultiplier } from "@/src/features/models/hooks/usePriceUnitMultiplier";
import { getMaxDecimals } from "@/src/features/models/utils";
import { type PriceUnit } from "@/src/features/models/validation";
export const PriceBreakdownTooltip = ({
modelName,
prices,
priceUnit,
rowHeight,
}: {
modelName: string;
prices?: Record<string, number>;
priceUnit: PriceUnit;
rowHeight: RowHeight;
}) => {
const [isOpen, setIsOpen] = useState(false);
const { priceUnitMultiplier } = usePriceUnitMultiplier();
const maxDecimals = useMemo(
() =>
Math.max(
...Object.values(prices ?? {}).map((price) => {
return getMaxDecimals(price, priceUnitMultiplier);
}),
),
[prices, priceUnitMultiplier],
);
if (!prices) return null;
return (
<>
{Object.keys(prices).length === 0 ? (
<p>No prices</p>
) : Object.keys(prices).length <= (rowHeight === "m" ? 4 : 2) ? (
<div className="grid w-full grid-cols-[2fr,3fr] gap-x-2">
{Object.entries(prices).map(([type, price]) => (
<>
<span
key={`${type}-label`}
className="truncate font-mono text-xs font-medium"
title={type}
>
{type}
</span>
<span
key={`${type}-price`}
className="text-left font-mono text-xs font-medium tabular-nums"
>
$
{new Decimal(price)
.mul(priceUnitMultiplier)
.toFixed(maxDecimals)}
</span>
</>
))}
</div>
) : (
<TooltipProvider>
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger
className="flex cursor-pointer items-center gap-2 pr-[1rem] text-xs"
onClick={() => setIsOpen(!isOpen)}
>
<InfoIcon className="h-3 w-3" />
{Object.keys(prices).length} prices set
</TooltipTrigger>
<TooltipContent className="min-w-[16rem] grow p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="font-semibold">Price breakdown</span>
<span className="font-mono text-xs font-medium">
{modelName}
</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between font-mono text-xs font-semibold">
<span className="mr-4">Usage Type</span>
<span>Price {priceUnit}</span>
</div>
{Object.entries(prices).map(([usageType, price]) => (
<div
key={usageType}
className="flex justify-between font-mono text-xs"
>
<span className="mr-4">{usageType}</span>
<span>
{"$" +
new Decimal(price)
.mul(priceUnitMultiplier)
.toFixed(maxDecimals)}
</span>
</div>
))}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</>
);
};
@@ -0,0 +1,81 @@
import Decimal from "decimal.js";
import { PriceMapSchema } from "@/src/features/models/validation";
import { getMaxDecimals } from "@/src/features/models/utils";
export function PricePreview({
prices,
}: {
prices: Record<string, number | undefined>;
}) {
const parsedPrices = PriceMapSchema.safeParse(prices);
const getMaxDecimalsForPriceGroup = (
price: number | undefined,
multiplier: number,
) => {
return price != null
? Math.max(
...Object.values(prices).map((price) => {
return getMaxDecimals(price, multiplier);
}),
)
: 0;
};
return (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-muted-foreground">
Price Preview
</h4>
</div>
{parsedPrices.success ? (
<div className="space-y-2">
<div className="grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 border-b border-border pb-2 text-xs font-medium text-muted-foreground">
<span>Usage Type</span>
<span className="text-right">per unit</span>
<span className="text-right">per 1K</span>
<span className="text-right">per 1M</span>
</div>
{Object.entries(parsedPrices.data)
.filter((entry): entry is [string, number] => Boolean(entry[1]))
.map(([usageType, price]) => (
<div
key={usageType}
className="grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 rounded px-1 py-0.5 text-xs text-muted-foreground"
>
<span className="break-all font-medium">{usageType}</span>
<span className="text-right font-mono">
$
{new Decimal(price).toFixed(
getMaxDecimalsForPriceGroup(price, 1),
)}
</span>
<span className="text-right font-mono">
$
{new Decimal(price)
.mul(1000)
.toFixed(getMaxDecimalsForPriceGroup(price, 1000))}
</span>
<span className="text-right font-mono">
$
{new Decimal(price)
.mul(1000000)
.toFixed(getMaxDecimalsForPriceGroup(price, 1000000))}
</span>
</div>
))}
</div>
) : (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
Invalid price entries. Please check your input format.
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import { ChevronDownIcon } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { PriceUnit } from "@/src/features/models/validation";
import { usePriceUnitMultiplier } from "@/src/features/models/hooks/usePriceUnitMultiplier";
export const PriceUnitSelector = () => {
const { priceUnit, setPriceUnit } = usePriceUnitMultiplier();
return (
<Popover>
<PopoverTrigger asChild>
<Button size="icon" variant="ghost">
<ChevronDownIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Select
value={priceUnit}
onValueChange={(value: PriceUnit) => setPriceUnit(value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select unit" />
</SelectTrigger>
<SelectContent>
{Object.values(PriceUnit).map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,486 @@
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";
import { JsonEditor } from "@/src/components/json-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<string, number>;
};
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<string | null>(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<FormUpsertModel>({
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}/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: 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<string, number>)
: undefined,
})
.catch((error) => {
setFormError(error.message);
});
};
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) return; // Only allow closing via cancel key
setOpen(open);
}}
dismissible={false}
onClose={() => {
form.reset();
setFormError(null);
}}
>
<DrawerTrigger
asChild
onClick={() => setOpen(true)}
className={props.className}
title={
props.action === "create"
? "Create model definition"
: "Edit model definition"
}
>
{children}
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<div className="flex items-center justify-between">
<DrawerTitle>
{props.action === "create"
? "Create Model"
: props.action === "clone"
? "Clone Model"
: "Edit Model"}
</DrawerTitle>
<Button
variant="ghost"
size="icon"
onClick={() => setOpen(false)}
type="button"
>
<X size={20} />
</Button>
</div>
<DrawerDescription>
{props.action === "edit"
? props.modelData.modelName
: props.action === "create"
? "Create a new model configuration to track generation costs."
: null}
</DrawerDescription>
</DrawerHeader>
<Form {...form}>
<form
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={form.handleSubmit(onSubmit)}
className="flex h-full max-h-[100vh] flex-col gap-6 overflow-y-auto p-4 pt-0"
>
<FormField
control={form.control}
name="modelName"
disabled={props.action === "edit"}
render={({ field }) => (
<FormItem>
<FormLabel>Model Name</FormLabel>
<FormDescription>
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.
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="matchPattern"
render={({ field }) => (
<FormItem>
<FormLabel>Match pattern</FormLabel>
<FormDescription>
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)$
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="prices"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Prices
</FormLabel>
<FormDescription>
Set prices per usage type for this model. Usage types must
exactly match the keys of the ingested usage details.
</FormDescription>
<span className="flex flex-col gap-2">
<FormDescription>
Prefill usage types from template:
</FormDescription>
<span className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
field.onChange({
input: 0,
output: 0,
input_cached_tokens: 0,
output_reasoning_tokens: 0,
...field.value,
});
}}
>
OpenAI
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
field.onChange({
input: 0,
input_tokens: 0,
output: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
...field.value,
});
}}
>
Anthropic
</Button>
</span>
</span>
<FormControl>
<span className="flex flex-col gap-2">
<FormDescription className="grid grid-cols-2 gap-1">
<span>Usage type</span>
<span>Price</span>
</FormDescription>
{Object.entries(field.value).map(
([key, value], index) => (
<div key={index} className="grid grid-cols-2 gap-1">
<Input
placeholder="Key (e.g. input, output)"
value={key}
onChange={(e) => {
const newPrices = { ...field.value };
const oldValue = newPrices[key];
delete newPrices[key];
newPrices[e.target.value] = oldValue;
field.onChange(newPrices);
}}
/>
<div className="flex gap-1">
<Input
type="number"
placeholder="Price per unit"
value={value}
step="0.000001"
onChange={(e) => {
field.onChange({
...field.value,
[key]: parseFloat(e.target.value),
});
}}
/>
<Button
type="button"
variant="outline"
title="Remove price"
size="icon"
onClick={() => {
const newPrices = { ...field.value };
delete newPrices[key];
field.onChange(newPrices);
}}
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
</div>
),
)}
<Button
type="button"
variant="ghost"
onClick={() => {
field.onChange({
...field.value,
new_usage_type: 0.000001,
});
}}
className="flex items-center gap-1"
>
<PlusCircle className="h-4 w-4" />
<span>Add Price</span>
</Button>
<PricePreview prices={field.value} />
</span>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenizerId"
render={({ field }) => (
<FormItem>
<FormLabel>Tokenizer</FormLabel>
<Select
onValueChange={(tokenizerId) => {
field.onChange(tokenizerId);
if (tokenizerId === "None") {
form.setValue("tokenizerConfig", "{}");
}
}}
defaultValue={field.value ?? "None"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a unit" />
</SelectTrigger>
</FormControl>
<SelectContent>
{["openai", "claude", "None"].map((unit) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
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{" "}
<Link
href="https://langfuse.com/docs/model-usage-and-cost"
className="underline"
target="_blank"
>
docs
</Link>
.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{tokenizerId && tokenizerId !== "None" && (
<FormField
control={form.control}
name="tokenizerConfig"
render={({ field }) => (
<FormItem>
<FormLabel>Tokenizer Config</FormLabel>
<JsonEditor
defaultValue={field.value ?? "{}"}
onChange={field.onChange}
/>
<FormDescription>
The config for the tokenizer. Required for openai. See the{" "}
<Link
href="https://langfuse.com/docs/model-usage-and-cost"
className="underline"
target="_blank"
>
docs
</Link>{" "}
for details.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<DrawerFooter className="flex-row gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
className="w-full"
>
Cancel
</Button>
<Button
type="submit"
className="w-full"
loading={upsertModelMutation.isLoading}
>
Submit
</Button>
</DrawerFooter>
</form>
{formError ? (
<p className="my-2 text-center text-sm font-medium text-destructive">
<span className="font-semibold">Error:</span> {formError}
</p>
) : null}
</Form>
</DrawerContent>
</Drawer>
);
};
@@ -0,0 +1,20 @@
import { useMemo } from "react";
import useLocalStorage from "@/src/components/useLocalStorage";
import { PriceUnit } from "@/src/features/models/validation";
export const multiplierMap: Record<PriceUnit, number> = {
[PriceUnit.PerUnit]: 1,
[PriceUnit.Per1KUnits]: 1e3,
[PriceUnit.Per1MUnits]: 1e6,
};
export const usePriceUnitMultiplier = () => {
const [priceUnit, setPriceUnit] = useLocalStorage<PriceUnit>(
"priceUnit",
PriceUnit.PerUnit,
);
const multiplier = useMemo(() => multiplierMap[priceUnit], [priceUnit]);
return { priceUnit, setPriceUnit, priceUnitMultiplier: multiplier };
};
+14
View File
@@ -0,0 +1,14 @@
import Decimal from "decimal.js";
export const getMaxDecimals = (
value: number | undefined,
scaleMultiplier: number = 1,
) => {
return (
new Decimal(value ?? 0)
.mul(scaleMultiplier)
.toFixed(12)
.split(".")[1]
?.replace(/0+$/, "").length ?? 0
);
};
+77
View File
@@ -0,0 +1,77 @@
import { z } from "zod";
export const UsageTypeSchema = z.string().regex(/^[a-zA-Z0-9_-]+$/);
export const PriceSchema = z.number().nonnegative();
export const TokenizerSchema = z.enum(["openai", "claude"]).nullish();
export const PriceMapSchema = z
.record(UsageTypeSchema, PriceSchema.optional())
.transform((obj) => {
return Object.fromEntries(
Object.entries(obj).filter(([_, value]) => Boolean(value)),
);
})
.pipe(z.record(UsageTypeSchema, PriceSchema));
export const GetModelResultSchema = z.object({
id: z.string(),
projectId: z.string().nullable(),
modelName: z.string(),
matchPattern: z.string(),
tokenizerConfig: z
.record(z.union([z.string(), z.coerce.number()]))
.nullable(),
tokenizerId: TokenizerSchema,
prices: PriceMapSchema,
});
export type GetModelResult = z.infer<typeof GetModelResultSchema>;
export const UpsertModelSchema = z.object({
modelId: z.string().nullable(),
projectId: z.string(),
modelName: z.string().min(1),
matchPattern: z.string().min(1),
tokenizerId: z
.enum(["openai", "claude", "None"])
.nullish()
.transform((value) => {
return value === "None" ? null : value;
})
.pipe(TokenizerSchema.nullish()),
tokenizerConfig: z
.record(z.union([z.string(), z.coerce.number()]))
.optional(),
prices: PriceMapSchema,
});
export type UpsertModel = z.infer<typeof UpsertModelSchema>;
export const FormUpsertModelSchema = z.object({
modelName: z.string().min(1),
matchPattern: z.string().min(1),
tokenizerId: z.enum(["openai", "claude", "None"]).nullish(),
tokenizerConfig: z
.string()
.refine(
(value) => {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
},
{
message: "Tokenizer config needs to be valid JSON",
},
)
.transform((value) => (value === "{}" ? undefined : value))
.nullish(),
prices: PriceMapSchema,
});
export type FormUpsertModel = z.infer<typeof FormUpsertModelSchema>;
export enum PriceUnit {
PerUnit = "per unit",
Per1KUnits = "per 1K units",
Per1MUnits = "per 1M units",
}
@@ -48,6 +48,7 @@ import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
import { useQueryParam } from "use-query-params";
import { Switch } from "@/src/components/ui/switch";
type NewPromptFormProps = {
initialPrompt?: Prompt | null;
@@ -61,6 +62,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
const [formError, setFormError] = useState<string | null>(null);
const { playgroundCache } = usePlaygroundCache();
const [initialMessages, setInitialMessages] = useState<unknown>([]);
const [showJsonEditor, setShowJsonEditor] = useState(false);
const utils = api.useUtils();
const capture = usePostHogClientCapture();
@@ -250,7 +252,24 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
{/* Prompt content field - text vs. chat */}
<>
<FormItem>
<FormLabel>Prompt</FormLabel>
<FormLabel className="flex flex-row items-center justify-between">
<div>Prompt</div>
{form.watch("type") === PromptType.Text ? (
<div className="flex flex-row items-center">
<p className="mr-1 text-xs text-muted-foreground">
JSON editor
</p>
<Switch
checked={showJsonEditor}
className={
showJsonEditor ? "data-[state=checked]:bg-dark-green" : ""
}
onCheckedChange={setShowJsonEditor}
/>
</div>
) : null}
</FormLabel>
<Tabs
value={form.watch("type")}
onValueChange={(e) => {
@@ -288,10 +307,18 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
render={({ field }) => (
<>
<FormControl>
<Textarea
{...field}
className="min-h-[200px] flex-1 font-mono text-xs"
/>
{showJsonEditor ? (
<JsonEditor
defaultValue={field.value}
onChange={field.onChange}
editable
/>
) : (
<Textarea
{...field}
className="min-h-[200px] flex-1 font-mono text-xs"
/>
)}
</FormControl>
<FormMessage />
</>
+49
View File
@@ -0,0 +1,49 @@
import {
transformCategoricalScoresToChartData,
uniqueAndSort,
} from "@/src/features/dashboard/lib/score-analytics-utils";
import {
type ChartBin,
type ChartData,
type TimeseriesDataTransformer,
} from "@/src/features/scores/types";
import { type DatabaseRow } from "@/src/server/api/services/queryBuilder";
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
export class DashboardCategoricalScoreAdapter
implements TimeseriesDataTransformer
{
constructor(
private data: DatabaseRow[],
private timestamp: string,
private agg?: DashboardDateRangeAggregationOption,
) {}
toChartData(): ChartData {
const { chartData, chartLabels } = transformCategoricalScoresToChartData(
this.data,
this.timestamp,
this.agg,
);
return { chartData, chartLabels: uniqueAndSort(chartLabels) };
}
}
export class CompareViewAdapter implements TimeseriesDataTransformer {
constructor(
private runMetrics: Map<
string,
{ chartData: ChartBin[]; chartLabels: string[] }
>,
private key: string,
) {}
toChartData(): ChartData {
return {
chartData: this.runMetrics.get(this.key)?.chartData ?? [],
chartLabels: uniqueAndSort(
this.runMetrics.get(this.key)?.chartLabels ?? [],
),
};
}
}
@@ -61,11 +61,11 @@ import { getDefaultScoreData } from "@/src/features/scores/lib/getDefaultScoreDa
import { ToggleGroup, ToggleGroupItem } from "@/src/components/ui/toggle-group";
import Header from "@/src/components/layouts/header";
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
import { CommandItem } from "@/src/components/ui/command";
import { useRouter } from "next/router";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { cn } from "@/src/utils/tailwind";
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
const AnnotationScoreDataSchema = z.object({
name: z.string(),
@@ -712,7 +712,7 @@ export function AnnotateDrawerContent({
key: field.configId as string,
}))}
controlButtons={
<CommandItem
<DropdownMenuItem
onSelect={() => {
capture("score_configs:manage_configs_item_click", {
type: type,
@@ -722,7 +722,7 @@ export function AnnotateDrawerContent({
}}
>
Manage score configs
</CommandItem>
</DropdownMenuItem>
}
/>
</div>
@@ -0,0 +1,80 @@
import { compactNumberFormatter } from "@/src/utils/numbers";
import { getColorsForCategories } from "@/src/features/dashboard/utils/getColorsForCategories";
import { isEmptyChart } from "@/src/features/dashboard/lib/score-analytics-utils";
import { BarChart, LineChart } from "@tremor/react";
import { NoDataOrLoading } from "@/src/components/NoDataOrLoading";
import { Card } from "@/src/components/ui/card";
import { type ChartBin } from "@/src/features/scores/types";
import { cn } from "@/src/utils/tailwind";
export function CategoricalChart(props: {
chartData: ChartBin[];
chartLabels: string[];
isLoading?: boolean;
stack?: boolean;
showXAxis?: boolean;
className?: string;
chartClass?: string;
}) {
const barCategoryGap = (chartLength: number): string => {
if (chartLength > 7) return "10%";
if (chartLength > 5) return "20%";
if (chartLength > 3) return "30%";
else return "40%";
};
const colors = getColorsForCategories(props.chartLabels);
return isEmptyChart({ data: props.chartData }) ? (
<NoDataOrLoading
isLoading={props.isLoading ?? false}
className={props.chartClass}
/>
) : (
<Card
className={cn("w-full rounded-tremor-default border", props.className)}
>
<BarChart
className={cn("mt-4", props.chartClass)}
data={props.chartData}
index="binLabel"
categories={props.chartLabels}
colors={colors}
valueFormatter={(number: number) =>
Intl.NumberFormat("en-US").format(number).toString()
}
yAxisWidth={48}
barCategoryGap={barCategoryGap(props.chartData.length)}
stack={props.stack ?? true}
showXAxis={props.showXAxis ?? true}
/>
</Card>
);
}
export function NumericChart(props: {
chartData: ChartBin[];
chartLabels: string[];
index: string;
}) {
const colors = getColorsForCategories(props.chartLabels);
return isEmptyChart({ data: props.chartData }) ? (
<NoDataOrLoading isLoading={false} />
) : (
<Card className="h-full w-full rounded-tremor-default border">
<LineChart
className="h-full"
data={props.chartData}
index={props.index}
categories={props.chartLabels}
colors={colors}
valueFormatter={compactNumberFormatter}
noDataText="No data"
showAnimation={true}
onValueChange={() => {}}
enableLegendSlider={true}
showXAxis={false}
/>
</Card>
);
}
@@ -0,0 +1,46 @@
import {
CategoricalChart,
NumericChart,
} from "@/src/features/scores/components/ScoreChart";
import { type TimeseriesChartProps } from "@/src/features/scores/types";
function ChartWrapper(props: { title: string; children: React.ReactNode }) {
return (
<div className="mb-2 flex w-[80%] flex-none flex-col overflow-hidden md:w-[45%]">
<div className="shrink-0 text-sm font-medium">{props.title}</div>
{props.children}
</div>
);
}
export function TimeseriesChart({
chartData,
chartLabels,
title,
type,
index,
}: TimeseriesChartProps) {
const chartIndex = index ?? "binLabel";
return (
<ChartWrapper title={title}>
<div className="mt-2 min-h-0 flex-1">
{type === "categorical" ? (
<CategoricalChart
chartLabels={chartLabels}
chartData={chartData}
className="h-full"
chartClass="h-full mt-0"
showXAxis={chartData.length < 3}
/>
) : (
<NumericChart
chartLabels={chartLabels}
chartData={chartData}
index={chartIndex}
/>
)}
</div>
</ChartWrapper>
);
}
@@ -1,23 +1,21 @@
import * as React from "react";
import { Archive, Check, ChevronDown } from "lucide-react";
import { Archive, ChevronDown, Component, Search } from "lucide-react";
import { cn } from "@/src/utils/tailwind";
import { Badge } from "@/src/components/ui/badge";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/src/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { Separator } from "@/src/components/ui/separator";
type MultiSelectOptions = {
@@ -28,6 +26,34 @@ type MultiSelectOptions = {
isArchived?: boolean;
};
type MultiSelectGroup = {
label: string;
options: MultiSelectOptions[];
};
type MultiSelectKeyValuesProps<
T extends { key: string; value: string } | string,
> = {
values: T[];
onValueChange: (
values: T[],
changedValue?: string,
selectedKeys?: Set<string>,
) => void;
options: MultiSelectOptions[] | readonly MultiSelectOptions[];
title?: string;
placeholder?: string;
groupedOptions?: MultiSelectGroup[];
className?: string;
disabled?: boolean;
items?: string;
align?: "center" | "end" | "start";
controlButtons?: React.ReactNode;
hideClearButton?: boolean;
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
};
export function MultiSelectKeyValues<
T extends { key: string; value: string } | string,
>({
@@ -36,29 +62,19 @@ export function MultiSelectKeyValues<
values,
onValueChange,
options,
groupedOptions,
className,
disabled,
items = "items",
align = "center",
controlButtons,
hideClearButton = false,
}: {
title?: string;
placeholder?: string;
values: T[];
onValueChange: (
values: T[],
changedValue?: string,
selectedKeys?: Set<string>,
) => void;
options: MultiSelectOptions[] | readonly MultiSelectOptions[];
className?: string;
disabled?: boolean;
items?: string;
align?: "center" | "end" | "start";
controlButtons?: React.ReactNode;
hideClearButton?: boolean;
}) {
iconLeft,
iconRight,
}: MultiSelectKeyValuesProps<T>) {
const [isOpen, setIsOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState("");
const selectedValueKeys = new Set(
values.map((value) => (typeof value === "string" ? value : value.key)),
);
@@ -69,7 +85,11 @@ export function MultiSelectKeyValues<
return Array.from(selectedValueKeys) as T[];
}
return options
const allOptions = groupedOptions
? groupedOptions.flatMap((group) => group.options)
: options || [];
return allOptions
.filter((option) => !!option.key && selectedValueKeys.has(option.key))
.map((option) => ({
key: option.key as string,
@@ -77,9 +97,77 @@ export function MultiSelectKeyValues<
})) as T[];
}
const filterOptions = (options: MultiSelectOptions[]) => {
if (!searchQuery.trim()) return options;
const searchLower = searchQuery.toLowerCase().trim();
return options.filter((option) => {
const valueLower = option.value.toLowerCase();
const keyLower = option.key?.toLowerCase() || "";
return valueLower.includes(searchLower) || keyLower.includes(searchLower);
});
};
const renderOption = (option: MultiSelectOptions) => {
const isSelected = selectedValueKeys.has(option.key ?? option.value);
return (
<DropdownMenuCheckboxItem
key={option.key ?? option.value}
checked={isSelected}
onSelect={(e) => e.preventDefault()}
onCheckedChange={() => {
const value = option.key ?? option.value;
if (isSelected) {
selectedValueKeys.delete(value);
} else {
selectedValueKeys.add(value);
}
const filterValues = formatFilterValues();
onValueChange(
filterValues.length ? filterValues : [],
value,
selectedValueKeys,
);
}}
disabled={option.disabled}
className="group"
>
<span
className={cn(
"capitalize",
option.isArchived ? "text-foreground/50" : "",
)}
>
{option.value}
</span>
{option.isArchived && (
<Archive className="ml-2 h-4 w-4 text-foreground/50" />
)}
{option.count !== undefined && (
<span className="ml-auto font-mono text-xs">{option.count}</span>
)}
</DropdownMenuCheckboxItem>
);
};
const inputRef = React.useRef<HTMLInputElement>(null);
const handleInputClick = (e: React.MouseEvent) => {
e.stopPropagation();
inputRef.current?.focus();
};
return (
<Popover modal>
<PopoverTrigger asChild>
<DropdownMenu
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setSearchQuery("");
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className={cn(
@@ -88,7 +176,9 @@ export function MultiSelectKeyValues<
)}
disabled={disabled}
>
{iconLeft}
{title}
{iconRight}
<ChevronDown className="h-4 w-4 opacity-50" />
{selectedValueKeys.size > 0 && (
<>
@@ -126,91 +216,84 @@ export function MultiSelectKeyValues<
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align={align}>
<Command>
<CommandInput placeholder={placeholder} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{Boolean(options.length) ? (
options.map((option) => {
const isSelected = selectedValueKeys.has(
option.key ?? option.value,
);
return (
<CommandItem
key={option.key ?? option.value}
value={option.key ?? option.value}
keywords={[option.value]}
onSelect={(value) => {
if (isSelected) {
selectedValueKeys.delete(value);
} else {
selectedValueKeys.add(value);
}
const filterValues = formatFilterValues();
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
className="w-[200px]"
onPointerDownOutside={() => setIsOpen(false)}
>
<div
className="flex items-center border-b px-2 py-1"
onClick={handleInputClick}
>
<Search className="mr-1 h-3 w-3 opacity-50" />
<Input
ref={inputRef}
placeholder={placeholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-6 border-0 bg-transparent p-0 text-sm focus-visible:ring-0"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{options &&
options.length > 0 &&
filterOptions(Array.from(options)).map(renderOption)}
onValueChange(
filterValues.length ? filterValues : [],
value,
selectedValueKeys,
);
}}
disabled={option.disabled}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
option.disabled ? "opacity-50" : null,
)}
>
<Check className="h-4 w-4" />
</div>
<span
className={cn(
"overflow-x-scroll capitalize",
option.isArchived ? "text-foreground/50" : "",
)}
>
{option.value}
</span>
{option.isArchived ? (
<div className="ml-1 mt-1 flex h-4 w-4">
<Archive className="h-4 w-4 text-foreground/50"></Archive>
</div>
) : null}
{option.count !== undefined ? (
<span className="ml-auto flex h-4 w-4 items-center justify-center pl-1 font-mono text-xs">
{option.count}
</span>
) : null}
</CommandItem>
);
})
) : (
<CommandItem disabled>No options found.</CommandItem>
)}
</CommandGroup>
{controlButtons || showClearItems ? (
<>
<CommandSeparator />
<CommandGroup heading="Controls">
{showClearItems && (
<CommandItem onSelect={() => onValueChange([])}>
Clear {items}
</CommandItem>
)}
{controlButtons}
</CommandGroup>
</>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{groupedOptions?.map((group) => {
const filteredGroupOptions = filterOptions(group.options);
if (filteredGroupOptions.length === 0) return null;
return (
<DropdownMenuSub key={group.label}>
<DropdownMenuSubTrigger className="flex w-full cursor-default select-none items-center">
<Component className="mr-2 h-4 w-4 opacity-50" />
<span>{group.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[300px] overflow-y-auto">
{filteredGroupOptions.map(renderOption)}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
})}
{searchQuery &&
(!options || filterOptions(Array.from(options)).length === 0) &&
(!groupedOptions ||
!groupedOptions.some(
(group) => filterOptions(group.options).length > 0,
)) && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
No results found.
</div>
)}
{showClearItems && !searchQuery && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
onValueChange([]);
}}
>
Clear {items}
</DropdownMenuItem>
</>
)}
{controlButtons && (
<>
<DropdownMenuSeparator />
{controlButtons}
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { type ScoreDataType } from "@langfuse/shared";
export type HistogramBin = { binLabel: string; count: number };
export type CategoryCounts = Record<string, number>;
export type ChartBin = { binLabel: string } & CategoryCounts;
export type TimeseriesChartProps = {
chartData: ChartBin[];
chartLabels: string[];
title: string;
type: "numeric" | "categorical";
index?: string;
};
export type ChartData = {
chartData: ChartBin[];
chartLabels: string[];
};
export type ScoreData = {
key: string;
name: string;
dataType: ScoreDataType;
source: string;
};
// Adapter interface to standardize data transformation
export interface TimeseriesDataTransformer {
toChartData(): ChartData;
}
@@ -6,7 +6,7 @@ import { setupTracingRoute } from "@/src/features/setup/setupRoutes";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { LockIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
const SetupTracingButton = () => {
@@ -27,10 +27,13 @@ const SetupTracingButton = () => {
},
);
// dedupe result via useRef, otherwise we'll capture the event multiple times on session refresh
const capturedEventAlready = useRef<boolean | undefined>(undefined);
const capture = usePostHogClientCapture();
useEffect(() => {
if (hasAnyTrace !== undefined) {
if (hasAnyTrace !== undefined && !capturedEventAlready.current) {
capture("onboarding:tracing_check_active", { active: hasAnyTrace });
capturedEventAlready.current = true;
}
}, [hasAnyTrace, capture]);
+14 -5
View File
@@ -12,7 +12,7 @@ import { QueryParamProvider } from "use-query-params";
import "@/src/styles/globals.css";
import Layout from "@/src/components/layouts/layout";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useRouter } from "next/router";
import posthog from "posthog-js";
@@ -62,6 +62,7 @@ if (
if (process.env.NODE_ENV === "development") posthog.debug();
},
autocapture: false,
enable_heatmaps: false,
});
}
@@ -126,8 +127,16 @@ function UserTracking() {
const sessionUser = session.data?.user;
const { organization, project } = useQueryProjectOrOrganization();
// dedupe the event via useRef, otherwise we'll capture the event multiple times on session refresh
const lastIdentifiedUser = useRef<string | null>(null);
useEffect(() => {
if (sessionUser) {
if (
session.status === "authenticated" &&
sessionUser &&
lastIdentifiedUser.current !== JSON.stringify(sessionUser)
) {
lastIdentifiedUser.current = JSON.stringify(sessionUser);
// PostHog
if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST)
posthog.identify(sessionUser.id ?? undefined, {
@@ -171,7 +180,8 @@ function UserTracking() {
: "undefined",
},
});
} else {
} else if (session.status === "unauthenticated") {
lastIdentifiedUser.current = null;
// PostHog
if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST) {
posthog.reset();
@@ -180,7 +190,7 @@ function UserTracking() {
// Sentry
setUser(null);
}
}, [sessionUser]);
}, [sessionUser, session.status]);
// update crisp segments
const plan = organization?.plan;
@@ -193,7 +203,6 @@ function UserTracking() {
useEffect(() => {
let segments = [];
if (plan && !currentOrgIsDemoOrg) {
console.log("setting chat segments", plan);
segments.push("plan:" + plan);
}
if (currentOrgIsDemoOrg) {
@@ -21,6 +21,20 @@ import {
} from "@/src/components/ui/dialog";
import { CreateExperimentsForm } from "@/src/ee/features/experiments/components/CreateExperimentsForm";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { DatasetAnalytics } from "@/src/features/datasets/components/DatasetAnalytics";
import { Card, CardContent } from "@/src/components/ui/card";
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
import { TimeseriesChart } from "@/src/features/scores/components/TimeseriesChart";
import {
isNumericDataType,
toOrderedScoresList,
} from "@/src/features/scores/lib/helpers";
import { CompareViewAdapter } from "@/src/features/scores/adapters";
import {
RESOURCE_METRICS,
transformAggregatedRunMetricsToChartData,
} from "@/src/features/dashboard/lib/score-analytics-utils";
export default function DatasetCompare() {
const router = useRouter();
@@ -29,11 +43,15 @@ export default function DatasetCompare() {
const [runState, setRunState] = useQueryParams({
runs: withDefault(ArrayParam, []),
});
const [isCreateExperimentDialogOpen, setIsCreateExperimentDialogOpen] =
useState(false);
const [localRuns, setLocalRuns] = useState<
Array<{ key: string; value: string }>
>([]);
const [selectedMetrics, setSelectedMetrics] = useState<string[]>(
RESOURCE_METRICS.map((metric) => metric.key),
);
const runIds = runState.runs as undefined | string[];
const hasExperimentWriteAccess = useHasProjectAccess({
@@ -59,6 +77,61 @@ export default function DatasetCompare() {
);
const utils = api.useUtils();
const runMetrics = api.datasets.runsByDatasetIdMetrics.useQuery(
{
projectId,
datasetId,
queryClickhouse: useClickhouse(),
runIds: runIds,
},
{
enabled: runIds && runIds.length > 1,
},
);
// LFE-3236: refactor to filter query to only include scores for runs in runIds
const scoreKeysAndProps = api.scores.getScoreKeysAndProps.useQuery(
{
projectId: projectId,
selectedTimeOption: { option: "All time", filterSource: "TABLE" },
queryClickhouse: useClickhouse(),
},
{
enabled: runIds && runIds.length > 1,
},
);
const scoreIdToName = useMemo(() => {
return new Map(
scoreKeysAndProps.data?.map((obj) => [obj.key, obj.name]) ?? [],
);
}, [scoreKeysAndProps.data]);
const runAggregatedMetrics = useMemo(() => {
return transformAggregatedRunMetricsToChartData(
runMetrics.data?.runs.filter((run) => runIds?.includes(run.id)) ?? [],
scoreIdToName,
);
}, [runMetrics.data, runIds, scoreIdToName]);
const { scoreAnalyticsOptions, scoreKeyToData } = useMemo(() => {
const scoreAnalyticsOptions = scoreKeysAndProps.data
? toOrderedScoresList(scoreKeysAndProps.data).map(
({ key, name, dataType, source }) => ({
key,
value: `${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`,
}),
)
: [];
return {
scoreAnalyticsOptions,
scoreKeyToData: new Map(
scoreKeysAndProps.data?.map((obj) => [obj.key, obj]) ?? [],
),
};
}, [scoreKeysAndProps.data]);
const handleExperimentSettled = async (data?: {
success: boolean;
datasetId: string;
@@ -89,7 +162,7 @@ export default function DatasetCompare() {
}
return (
<FullScreenPage key={runIds?.join(",") ?? "empty"}>
<FullScreenPage>
<Header
title={`Compare runs: ${dataset.data?.name ?? datasetId}`}
breadcrumb={[
@@ -154,6 +227,15 @@ export default function DatasetCompare() {
</div>
</PopoverContent>
</Popover>,
runIds && runIds.length > 1 ? (
<DatasetAnalytics
key="dataset-analytics"
projectId={projectId}
scoreOptions={scoreAnalyticsOptions}
selectedMetrics={selectedMetrics}
setSelectedMetrics={setSelectedMetrics}
/>
) : null,
<MultiSelectKeyValues
key="select-runs"
title="Select runs"
@@ -185,6 +267,52 @@ export default function DatasetCompare() {
/>,
]}
/>
{Boolean(selectedMetrics.length) &&
Boolean(runAggregatedMetrics?.size) && (
<Card className="my-4 max-h-[25dvh] md:max-h-[30dvh]">
<CardContent className="mt-2 h-full">
<div className="flex h-full w-full gap-4 overflow-x-auto">
{selectedMetrics.map((key) => {
const adapter = new CompareViewAdapter(
runAggregatedMetrics,
key,
);
const { chartData, chartLabels } = adapter.toChartData();
const scoreData = scoreKeyToData.get(key);
if (!scoreData)
return (
<TimeseriesChart
key={key}
chartData={chartData}
chartLabels={chartLabels}
title={
RESOURCE_METRICS.find((metric) => metric.key === key)
?.label ?? key
}
type="numeric"
/>
);
return (
<TimeseriesChart
key={key}
chartData={chartData}
chartLabels={chartLabels}
title={`${getScoreDataTypeIcon(scoreData.dataType)} ${scoreData.name} (${scoreData.source.toLowerCase()})`}
type={
isNumericDataType(scoreData.dataType)
? "numeric"
: "categorical"
}
/>
);
})}
</div>
</CardContent>
</Card>
)}
<DatasetCompareRunsTable
key={runIds?.join(",") ?? "empty"}
projectId={projectId}
@@ -7,13 +7,11 @@ import Link from "next/link";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { DatasetActionButton } from "@/src/features/datasets/components/DatasetActionButton";
import { DeleteButton } from "@/src/components/deleteButton";
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
import { DuplicateDatasetButton } from "@/src/features/datasets/components/DuplicateDatasetButton";
import { useState } from "react";
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
import { CommandItem } from "@/src/components/ui/command";
import { ExternalLink, FlaskConical } from "lucide-react";
import { ExternalLink, FlaskConical, FolderKanban } from "lucide-react";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { useMemo } from "react";
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
@@ -25,6 +23,15 @@ import {
import { Button } from "@/src/components/ui/button";
import { CreateExperimentsForm } from "@/src/ee/features/experiments/components/CreateExperimentsForm";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
import { DatasetAnalytics } from "@/src/features/datasets/components/DatasetAnalytics";
import { RESOURCE_METRICS } from "@/src/features/dashboard/lib/score-analytics-utils";
import { MarkdownOrJsonView } from "@/src/components/trace/IOPreview";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
export default function Dataset() {
const router = useRouter();
@@ -34,6 +41,15 @@ export default function Dataset() {
const hasEntitlement = useHasEntitlement("model-based-evaluations");
const [isCreateExperimentDialogOpen, setIsCreateExperimentDialogOpen] =
useState(false);
const [selectedMetrics, setSelectedMetrics] = useState<string[]>(
RESOURCE_METRICS.map((metric) => metric.key),
);
const [scoreOptions, setScoreOptions] = useState<
{
key: string;
value: string;
}[]
>([]);
const dataset = api.datasets.byId.useQuery({
datasetId,
@@ -132,6 +148,14 @@ export default function Dataset() {
</DialogContent>
</Dialog>
<DatasetAnalytics
key="dataset-analytics"
projectId={projectId}
scoreOptions={scoreOptions}
selectedMetrics={selectedMetrics}
setSelectedMetrics={setSelectedMetrics}
/>
{hasReadAccess && hasEntitlement && evaluators.isSuccess && (
<MultiSelectKeyValues
className="max-w-fit"
@@ -148,17 +172,41 @@ export default function Dataset() {
values={evaluatorsOptions}
options={evaluatorsOptions}
controlButtons={
<CommandItem
<DropdownMenuItem
onSelect={() => {
window.open(`/project/${projectId}/evals`, "_blank");
}}
>
Manage evaluators
<ExternalLink className="ml-auto h-4 w-4" />
</CommandItem>
</DropdownMenuItem>
}
/>
)}
<Popover key="show-dataset-details">
<PopoverTrigger asChild>
<Button variant="outline">
<FolderKanban className="mr-2 h-4 w-4" />
Dataset details
</Button>
</PopoverTrigger>
<PopoverContent className="mx-2 max-h-[50vh] w-[50vw] overflow-y-auto md:w-[25vw]">
<div className="space-y-4">
<div>
<h4 className="mb-1 font-medium">Description</h4>
<span className="text-sm text-muted-foreground">
{dataset.data?.description ?? "No description"}
</span>
</div>
<div>
<h4 className="mb-1 font-medium">Metadata</h4>
<MarkdownOrJsonView
content={dataset.data?.metadata ?? null}
/>
</div>
</div>
</PopoverContent>
</Popover>
<DetailPageNav
currentId={datasetId}
path={(entry) => `/project/${projectId}/datasets/${entry.id}`}
@@ -190,17 +238,12 @@ export default function Dataset() {
</>
}
/>
{!!dataset.data?.metadata && (
<JSONView
json={dataset?.data.metadata}
title="Metadata"
className="max-h-[25vh] overflow-y-auto"
/>
)}
<DatasetRunsTable
projectId={projectId}
datasetId={datasetId}
selectedMetrics={selectedMetrics}
setScoreOptions={setScoreOptions}
menuItems={
<Tabs value="runs">
<TabsList>
+12 -17
View File
@@ -1,13 +1,12 @@
import Header from "@/src/components/layouts/header";
import { useRouter } from "next/router";
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
import Header from "@/src/components/layouts/header";
import ModelTable from "@/src/components/table/use-cases/models";
import { Button } from "@/src/components/ui/button";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { Lock } from "lucide-react";
import Link from "next/link";
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
export default function ModelsPage() {
const router = useRouter();
@@ -27,19 +26,15 @@ export default function ModelsPage() {
href: "https://langfuse.com/docs/model-usage-and-cost",
}}
actionButtons={
<Button
variant="secondary"
disabled={!hasWriteAccess}
onClick={() => capture("models:new_form_open")}
asChild
>
<Link
href={hasWriteAccess ? `/project/${projectId}/models/new` : "#"}
<UpsertModelFormDrawer {...{ projectId, action: "create" }}>
<Button
variant="secondary"
disabled={!hasWriteAccess}
onClick={() => capture("models:new_form_open")}
>
{!hasWriteAccess && <Lock size={16} className="mr-2" />}
Add model definition
</Link>
</Button>
</Button>
</UpsertModelFormDrawer>
}
/>
<ModelTable projectId={projectId} />
@@ -0,0 +1,208 @@
import { useRouter } from "next/router";
import { api } from "@/src/utils/api";
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
import Header from "@/src/components/layouts/header";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/src/components/ui/card";
import { DeleteModelButton } from "@/src/features/models/components/DeleteModelButton";
import { EditModelButton } from "@/src/features/models/components/EditModelButton";
import { CloneModelButton } from "@/src/features/models/components/CloneModelButton";
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import Link from "next/link";
import { Button } from "@/src/components/ui/button";
import { getMaxDecimals } from "@/src/features/models/utils";
import Decimal from "decimal.js";
import { PriceUnitSelector } from "@/src/features/models/components/PriceUnitSelector";
import { useMemo } from "react";
import { usePriceUnitMultiplier } from "@/src/features/models/hooks/usePriceUnitMultiplier";
import Generations from "@/src/components/table/use-cases/generations";
import { ArrowTopRightIcon } from "@radix-ui/react-icons";
export default function ModelDetailPage() {
const router = useRouter();
const { priceUnit, priceUnitMultiplier } = usePriceUnitMultiplier();
const projectId = router.query.projectId as string;
const modelId = router.query.modelId as string;
const hasWriteAccess = useHasProjectAccess({
projectId,
scope: "models:CUD",
});
const { data: model, isLoading } = api.models.getById.useQuery(
{ projectId, modelId },
{ enabled: !!projectId && !!modelId },
);
const maxDecimals = useMemo(
() =>
Math.max(
...Object.values(model?.prices ?? {}).map((price) =>
getMaxDecimals(price, priceUnitMultiplier),
),
),
[model?.prices, priceUnitMultiplier],
);
// If not found, redirect to models page
if (!isLoading && !model) {
return (
<div className="flex min-h-screen flex-col items-center justify-center">
<div className="mb-4 text-xl font-medium">Model not found</div>
<Button variant="outline" asChild>
<Link href={`/project/${projectId}/models`}>
Return to Models page
</Link>
</Button>
</div>
);
}
const isLangfuseModel = !Boolean(model?.projectId);
if (isLoading || !model) {
return <div>Loading...</div>;
}
return (
<FullScreenPage>
<Header
title={model.modelName}
help={{
description: "Model configuration and pricing details",
href: "https://langfuse.com/docs/model-usage-and-cost",
}}
breadcrumb={[
{
name: "Models",
href: `/project/${router.query.projectId as string}/models`,
},
{ name: model.modelName },
]}
actionButtons={
<div className="flex gap-2">
{hasWriteAccess &&
(!isLangfuseModel ? (
<>
<EditModelButton projectId={projectId} modelData={model} />
<DeleteModelButton
projectId={projectId}
modelData={model}
onSuccess={() => {
void router.push(`/project/${projectId}/models`);
}}
/>
</>
) : (
<CloneModelButton projectId={projectId} modelData={model} />
))}
</div>
}
/>
<div className="grid grid-cols-2 gap-6 p-2">
<Card>
<CardHeader>
<CardTitle>Model configuration</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">
Match Pattern
</div>
<div className="mt-1 font-mono text-sm">{model.matchPattern}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Maintained by
</div>
<div className="mt-1 text-sm">
{isLangfuseModel ? "Langfuse" : "User"}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Tokenizer
</div>
<div className="mt-1 text-sm">{model.tokenizerId || "None"}</div>
</div>
{model.tokenizerId && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Tokenizer Config
</div>
<pre className="mt-1 rounded bg-muted p-2 text-sm">
<JSONView json={model.tokenizerConfig} />
</pre>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pricing</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2 gap-2 border-b border-border text-sm font-medium text-muted-foreground">
<span>Usage Type</span>
<span className="flex items-center gap-2">
<span>Price {priceUnit}</span>
<PriceUnitSelector />
</span>
</div>
{Object.entries(model.prices).map(([usageType, price]) => (
<div
key={usageType}
className="grid grid-cols-2 gap-2 rounded px-1 py-0.5 text-sm"
>
<span className="break-all">{usageType}</span>
<span className="text-left font-mono">
$
{new Decimal(price)
.mul(priceUnitMultiplier)
.toFixed(maxDecimals)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="col-span-2">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Model generations</span>
<Button variant="ghost" asChild>
<Link
href={`/project/${projectId}/generations`}
className="flex items-center gap-1"
>
<span className="text-sm">View all</span>
<ArrowTopRightIcon className="h-4 w-4" />
</Link>
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex max-h-[calc(100vh-20rem)] flex-col">
<Generations
projectId={projectId}
omittedFilter={["Model"]}
modelId={model.id}
/>
</div>
</CardContent>
</Card>
</div>
</FullScreenPage>
);
}
@@ -1,36 +0,0 @@
import Header from "@/src/components/layouts/header";
import { useRouter } from "next/router";
import { NewModelForm } from "@/src/features/models/components/NewModelForm";
import { ScrollScreenPage } from "@/src/components/layouts/scroll-screen-page";
export default function ModelsPage() {
const router = useRouter();
const projectId = router.query.projectId as string;
return (
<ScrollScreenPage>
<Header
title="New Model Definition"
breadcrumb={[
{
name: "Models",
href: `/project/${projectId}/models`,
},
{
name: "New",
},
]}
help={{
description:
"Create a project-specific model definition. This will be used by Langfuse to infer model usage (eg tokens) and cost (USD).",
href: "https://langfuse.com/docs/model-usage-and-cost",
}}
/>
<NewModelForm
projectId={projectId}
onFormSuccess={() => void router.push(`/project/${projectId}/models`)}
/>
</ScrollScreenPage>
);
}
@@ -89,6 +89,7 @@ export default function UsersPage() {
projectId,
userIds: users.data?.users.map((u) => u.userId) ?? [],
queryClickhouse: useClickhouse(),
filter: filterState,
},
{
enabled: users.isSuccess,
@@ -11,6 +11,7 @@ import {
datetimeFilterToPrisma,
datetimeFilterToPrismaSql,
getObservationsGroupedByModel,
getObservationsGroupedByModelId,
getObservationsGroupedByName,
getObservationsGroupedByPromptName,
getScoresGroupedByName,
@@ -87,7 +88,7 @@ export const filterOptionsQuery = protectedProjectProcedure
};
// Score names
const [scores, model, name, promptNames, traceNames, tags] =
const [scores, model, name, promptNames, traceNames, tags, modelId] =
await measureAndReturnApi({
input,
operation: "traces.all",
@@ -175,6 +176,8 @@ export const filterOptionsQuery = protectedProjectProcedure
${rawStartTimeFilter}
LIMIT 1000;
`),
// modelId
[] as any[],
]);
},
clickhouseExecution: async () => {
@@ -212,6 +215,11 @@ export const filterOptionsQuery = protectedProjectProcedure
getClickhouseTraceName(),
// trace tags
getClickhouseTraceTags(),
// modelId
getObservationsGroupedByModelId(
input.projectId,
startTimeFilter ? [startTimeFilter] : [],
),
]);
},
});
@@ -221,6 +229,11 @@ export const filterOptionsQuery = protectedProjectProcedure
model: model
.filter((i) => i.model !== null)
.map((i) => ({ value: i.model as string })),
modelId: modelId
.filter((i) => i.modelId !== null)
.map((i) => ({
value: i.modelId as string,
})),
name: name
.filter((i) => i.name !== null)
.map((i) => ({ value: i.name as string })),
+222 -132
View File
@@ -1,15 +1,19 @@
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { ModelUsageUnit } from "@langfuse/shared";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { isValidPostgresRegex } from "@/src/features/models/server/isValidPostgresRegex";
import {
GetModelResultSchema,
UpsertModelSchema,
} from "@/src/features/models/validation";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { paginationZod } from "@langfuse/shared";
import { ModelUsageUnit, paginationZod } from "@langfuse/shared";
import { TRPCError } from "@trpc/server";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { isValidPostgresRegex } from "@/src/features/models/server/isValidPostgresRegex";
const ModelAllOptions = z.object({
projectId: z.string(),
@@ -17,53 +21,228 @@ const ModelAllOptions = z.object({
});
export const modelRouter = createTRPCRouter({
all: protectedProjectProcedure
getById: protectedProjectProcedure
.input(z.object({ projectId: z.string(), modelId: z.string() }))
.query(async ({ input, ctx }) => {
const modelQueryResult = await ctx.prisma.$queryRaw`
SELECT
m.id,
m.project_id as "projectId",
m.model_name as "modelName",
m.match_pattern as "matchPattern",
m.tokenizer_config as "tokenizerConfig",
m.tokenizer_id as "tokenizerId",
COALESCE(
(
SELECT
JSONB_OBJECT_AGG(usage_type, price)
FROM
prices
WHERE
model_id = m.id
),
'{}'::jsonb
) AS prices
FROM
models m
WHERE
m.id = ${input.modelId}
AND (
project_id IS NULL
OR project_id = ${input.projectId}
);
`;
const model = z.array(GetModelResultSchema).parse(modelQueryResult)[0];
if (!model || (model.projectId && model.projectId !== input.projectId)) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Model not found",
});
}
return model;
}),
getAll: protectedProjectProcedure
.input(ModelAllOptions)
.query(async ({ input, ctx }) => {
const [models, totalAmount] = await Promise.all([
ctx.prisma.model.findMany({
where: {
OR: [{ projectId: input.projectId }, { projectId: null }],
},
skip: input.page * input.limit,
orderBy: [
{ modelName: "asc" },
{ unit: "asc" },
{
startDate: {
sort: "desc",
nulls: "last",
},
},
],
take: input.limit,
}),
ctx.prisma.model.count({
where: {
OR: [{ projectId: input.projectId }, { projectId: null }],
},
}),
const [allModelsQueryResult, totalCountQuery] = await Promise.all([
// All models
ctx.prisma.$queryRaw`
SELECT DISTINCT ON (project_id, model_name)
m.id,
m.project_id as "projectId",
m.model_name as "modelName",
m.match_pattern as "matchPattern",
m.tokenizer_config as "tokenizerConfig",
m.tokenizer_id as "tokenizerId",
COALESCE(
(
SELECT
JSONB_OBJECT_AGG(usage_type, price)
FROM
prices
WHERE
model_id = m.id
),
'{}'::jsonb
) AS prices
FROM
models m
WHERE
project_id IS NULL
OR project_id = ${input.projectId}
ORDER BY
project_id,
model_name,
m.created_at DESC NULLS LAST
LIMIT ${input.limit} OFFSET ${input.page * input.limit};
`,
// Total count
ctx.prisma.$queryRaw<
{
count: number;
}[]
>`
SELECT COUNT(DISTINCT (project_id, model_name))
FROM models
WHERE project_id IS NULL
OR project_id = '${input.projectId}';
`,
]);
const allModels = z
.array(GetModelResultSchema)
.parse(allModelsQueryResult);
const totalCount = z.coerce.number().parse(totalCountQuery[0].count);
return {
models,
totalCount: totalAmount,
models: allModels,
totalCount,
};
}),
modelNames: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
return (
await ctx.prisma.model.findMany({
select: {
modelName: true,
},
distinct: ["modelName"],
orderBy: [{ modelName: "asc" }],
upsert: protectedProjectProcedure
.input(UpsertModelSchema)
.mutation(async ({ input, ctx }) => {
const {
modelId: providedModelId,
projectId,
modelName,
matchPattern,
tokenizerConfig,
tokenizerId,
} = input;
throwIfNoProjectAccess({
session: ctx.session,
projectId,
scope: "models:CUD",
});
// Check if regex is valid POSIX regex
// Use DB to check, because JS regex is not POSIX compliant
const isValidRegex = await isValidPostgresRegex(
input.matchPattern,
ctx.prisma,
);
if (!isValidRegex) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid regex, needs to be Postgres syntax",
});
}
const modelId = providedModelId ?? uuidv4();
return await ctx.prisma.$transaction(async (tx) => {
// Check whether model belongs to project
// This check is important to prevent users from updating prices for models that they do not have access to
const existingModel = await tx.model.findUnique({
where: {
OR: [{ projectId: input.projectId }, { projectId: null }],
id: modelId,
},
})
).map((model) => model.modelName);
});
if (existingModel && existingModel.projectId !== projectId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Model not found",
});
}
// Check if model name is unique within the project
// Note: The database has a uniqueness constraint on (projectId, modelName, startDate, unit),
// but this constraint is not enforced when startDate or unit are NULL.
// We do an explicit check here to ensure uniqueness on just (projectId, modelName).
// TODO(LFE-3229): After models table cleanup, enforce uniqueness constraint directly on (projectId, modelName)
const existingModelName = await tx.model.findFirst({
where: {
projectId,
modelName,
},
});
if (existingModelName && modelId !== existingModelName.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Model name '${modelName}' already exists in project`,
});
}
const upsertedModel = await tx.model.upsert({
where: {
id: modelId,
projectId: projectId,
},
create: {
id: modelId,
projectId,
modelName,
matchPattern,
tokenizerConfig,
tokenizerId,
startDate: new Date("2010-01-01"), // Set fix start date for uniqueness constraint to work. TODO: drop after cleanup of models table in LFE-3229
unit: ModelUsageUnit.Tokens, // Set fix unit for uniqueness constraint to work. TODO: drop after cleanup of models table in LFE-3229
},
update: {
matchPattern,
tokenizerConfig,
tokenizerId,
},
});
await tx.price.deleteMany({
where: {
modelId: upsertedModel.id,
},
});
await tx.price.createMany({
data: Object.entries(input.prices)
.filter(
(priceEntry): priceEntry is [string, number] =>
priceEntry[1] != null,
)
.map(([usageType, price]) => ({
modelId: upsertedModel.id,
usageType,
price,
})),
});
await auditLog({
session: ctx.session,
resourceType: "model",
resourceId: upsertedModel.id,
action: modelId ? "update" : "create",
after: upsertedModel,
});
return upsertedModel;
});
}),
delete: protectedProjectProcedure
.input(
@@ -96,93 +275,4 @@ export const modelRouter = createTRPCRouter({
return deletedModel;
}),
create: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
modelName: z.string(),
matchPattern: z.string(),
startDate: z.date().optional(),
inputPrice: z.number().nonnegative().optional(),
outputPrice: z.number().nonnegative().optional(),
totalPrice: z.number().nonnegative().optional(),
unit: z.nativeEnum(ModelUsageUnit),
tokenizerId: z.enum(["openai", "claude"]).optional(),
tokenizerConfig: z.record(z.union([z.string(), z.number()])).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "models:CUD",
});
// Check if regex is valid POSIX regex
// Use DB to check, because JS regex is not POSIX compliant
const isValidRegex = await isValidPostgresRegex(
input.matchPattern,
ctx.prisma,
);
if (!isValidRegex) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid regex, needs to be Postgres syntax",
});
}
const createdModel = await ctx.prisma.$transaction(async (tx) => {
const createdModel = await tx.model.create({
data: {
projectId: input.projectId,
modelName: input.modelName,
matchPattern: input.matchPattern,
startDate: input.startDate,
inputPrice: input.inputPrice,
outputPrice: input.outputPrice,
totalPrice: input.totalPrice,
unit: input.unit,
tokenizerId: input.tokenizerId,
tokenizerConfig: input.tokenizerConfig,
},
});
// Populate prices table
const prices = [
{ usageType: "input", price: input.inputPrice },
{ usageType: "output", price: input.outputPrice },
{ usageType: "total", price: input.totalPrice },
];
const pricesToCreate = [];
for (const { usageType, price } of prices) {
if (price != null) {
pricesToCreate.push(
tx.price.create({
data: {
modelId: createdModel.id,
usageType,
price,
},
}),
);
}
}
await Promise.all(pricesToCreate);
return createdModel;
});
await auditLog({
session: ctx.session,
resourceType: "model",
resourceId: createdModel.id,
action: "create",
after: createdModel,
});
return createdModel;
}),
});
+1 -7
View File
@@ -64,13 +64,7 @@ type TraceFilterOptions = z.infer<typeof TraceFilterOptions>;
export type ObservationReturnType = Omit<
ObservationView,
| "input"
| "output"
| "modelId"
| "inputPrice"
| "outputPrice"
| "totalPrice"
| "metadata"
"input" | "output" | "inputPrice" | "outputPrice" | "totalPrice" | "metadata"
> & {
traceId: string;
usageDetails: Record<string, number>;
+7 -2
View File
@@ -121,6 +121,7 @@ export const userRouter = createTRPCRouter({
projectId: z.string(),
userIds: z.array(z.string().min(1)),
queryClickhouse: z.boolean().default(false),
filter: z.array(singleFilter).nullable(),
}),
)
.query(async ({ input, ctx }) => {
@@ -182,7 +183,11 @@ export const userRouter = createTRPCRouter({
if (input.userIds.length === 0) {
return [];
}
const metrics = await getUserMetrics(input.projectId, input.userIds);
const metrics = await getUserMetrics(
input.projectId,
input.userIds,
input.filter ?? [],
);
return metrics.map((metric) => ({
userId: metric.userId,
@@ -262,7 +267,7 @@ export const userRouter = createTRPCRouter({
},
clickhouseExecution: async () => {
const result = (
await getUserMetrics(input.projectId, [input.userId])
await getUserMetrics(input.projectId, [input.userId], [])
).shift();
return {
+4
View File
@@ -189,6 +189,10 @@ if (
authorization: {
params: { scope: env.AUTH_CUSTOM_SCOPE ?? "openid email profile" },
},
client: {
token_endpoint_auth_method:
env.AUTH_CUSTOM_CLIENT_AUTH_METHOD ?? "client_secret_basic",
},
}),
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.3.0",
"version": "3.4.0",
"description": "",
"license": "MIT",
"private": true,
@@ -353,16 +353,22 @@ describe("batch export test suite", () => {
project_id: projectId,
trace_id: traces[0].id,
type: "GENERATION",
start_time: new Date().getTime() - 1000,
end_time: new Date().getTime(),
}),
createObservation({
project_id: projectId,
trace_id: traces[1].id,
type: "GENERATION",
start_time: new Date().getTime() - 2000,
end_time: new Date().getTime(),
}),
createObservation({
project_id: projectId,
trace_id: traces[1].id,
type: "GENERATION",
start_time: new Date().getTime() - 2123,
end_time: new Date().getTime(),
}),
];
@@ -397,10 +403,12 @@ describe("batch export test suite", () => {
expect.arrayContaining([
expect.objectContaining({
id: traces[0].id,
latency: 1,
test: [score.value],
}),
expect.objectContaining({
id: traces[1].id,
latency: 2.123,
}),
]),
);
+16 -16
View File
@@ -763,8 +763,8 @@ describe("eval service tests", () => {
id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
user_id: "a",
input: { input: "This is a great prompt" },
output: { output: "This is a great response" },
input: JSON.stringify({ input: "This is a great prompt" }),
output: JSON.stringify({ output: "This is a great response" }),
timestamp: convertDateToClickhouseDateTime(new Date()),
created_at: convertDateToClickhouseDateTime(new Date()),
updated_at: convertDateToClickhouseDateTime(new Date()),
@@ -1233,8 +1233,8 @@ describe("eval service tests", () => {
id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
user_id: "a",
input: { input: "This is a great prompt" },
output: { output: "This is a great response" },
input: JSON.stringify({ input: "This is a great prompt" }),
output: JSON.stringify({ output: "This is a great response" }),
timestamp: convertDateToClickhouseDateTime(new Date()),
created_at: convertDateToClickhouseDateTime(new Date()),
updated_at: convertDateToClickhouseDateTime(new Date()),
@@ -1290,8 +1290,8 @@ describe("eval service tests", () => {
id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
user_id: "a",
input: { input: "This is a great prompt" },
output: { output: "This is a great response" },
input: JSON.stringify({ input: "This is a great prompt" }),
output: JSON.stringify({ output: "This is a great response" }),
timestamp: convertDateToClickhouseDateTime(new Date()),
created_at: convertDateToClickhouseDateTime(new Date()),
updated_at: convertDateToClickhouseDateTime(new Date()),
@@ -1316,8 +1316,8 @@ describe("eval service tests", () => {
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
name: "great-llm-name",
type: "GENERATION",
input: { huhu: "This is a great prompt" },
output: { haha: "This is a great response" },
input: JSON.stringify({ huhu: "This is a great prompt" }),
output: JSON.stringify({ haha: "This is a great response" }),
start_time: convertDateToClickhouseDateTime(new Date()),
created_at: convertDateToClickhouseDateTime(new Date()),
updated_at: convertDateToClickhouseDateTime(new Date()),
@@ -1418,8 +1418,8 @@ describe("eval service tests", () => {
id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
user_id: "a",
input: { input: "This is a great prompt" },
output: { output: "This is a great response" },
input: JSON.stringify({ input: "This is a great prompt" }),
output: JSON.stringify({ output: "This is a great response" }),
timestamp: convertDateToClickhouseDateTime(new Date()),
created_at: convertDateToClickhouseDateTime(new Date()),
updated_at: convertDateToClickhouseDateTime(new Date()),
@@ -1500,8 +1500,8 @@ describe("eval service tests", () => {
id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
user_id: "a",
input: { input: "This is a great prompt" },
output: { output: "This is a great response" },
input: JSON.stringify({ input: "This is a great prompt" }),
output: JSON.stringify({ output: "This is a great response" }),
timestamp: convertDateToClickhouseDateTime(new Date()),
created_at: convertDateToClickhouseDateTime(new Date()),
updated_at: convertDateToClickhouseDateTime(new Date()),
@@ -1527,8 +1527,8 @@ describe("eval service tests", () => {
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
name: "great-llm-name",
type: "GENERATION",
input: { huhu: "This is a great prompt" },
output: { haha: "This is a great response" },
input: JSON.stringify({ huhu: "This is a great prompt" }),
output: JSON.stringify({ haha: "This is a great response" }),
start_time: convertDateToClickhouseDateTime(
new Date("2022-01-01T00:00:00.000Z"),
),
@@ -1556,8 +1556,8 @@ describe("eval service tests", () => {
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
name: "great-llm-name",
type: "GENERATION",
input: { huhu: "This is a great prompt again" },
output: { haha: "This is a great response again" },
input: JSON.stringify({ huhu: "This is a great prompt again" }),
output: JSON.stringify({ haha: "This is a great response again" }),
start_time: convertDateToClickhouseDateTime(
new Date("2022-01-02T00:00:00.000Z"),
),
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.3.0";
export const VERSION = "v3.4.0";
@@ -401,6 +401,7 @@ export const getDatabaseReadStream = async ({
input: fullTrace?.input,
output: fullTrace?.output,
metadata: fullTrace?.metadata,
latency: metric?.latency,
name: t.name ?? "",
usage: {
promptTokens: metric?.promptTokens,