224 lines
7.8 KiB
TypeScript
224 lines
7.8 KiB
TypeScript
import { ArrowsRightLeftIcon } from "@heroicons/react/20/solid";
|
|
import { type MetaFunction } from "@remix-run/react";
|
|
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
|
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
|
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
|
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
|
import { LinkButton } from "~/components/primitives/Buttons";
|
|
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHeader,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
} from "~/components/primitives/Table";
|
|
import { findProjectBySlug } from "~/models/project.server";
|
|
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
|
import {
|
|
type ModelComparisonItem,
|
|
ModelRegistryPresenter,
|
|
} from "~/presenters/v3/ModelRegistryPresenter.server";
|
|
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useProject } from "~/hooks/useProject";
|
|
import { useEnvironment } from "~/hooks/useEnvironment";
|
|
import { EnvironmentParamSchema, v3ModelsPath } from "~/utils/pathBuilder";
|
|
import { formatModelCost } from "~/utils/modelFormatters";
|
|
import { formatNumberCompact } from "~/utils/numberFormatter";
|
|
|
|
export const meta: MetaFunction = () => {
|
|
return [{ title: "Compare Models | Trigger.dev" }];
|
|
};
|
|
|
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|
const userId = await requireUserId(request);
|
|
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
|
if (!project) {
|
|
throw new Response("Project not found", { status: 404 });
|
|
}
|
|
|
|
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
|
if (!environment) {
|
|
throw new Response("Environment not found", { status: 404 });
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
const modelsParam = url.searchParams.get("models") ?? "";
|
|
const responseModels = modelsParam.split(",").filter(Boolean).slice(0, 4);
|
|
|
|
if (responseModels.length < 2) {
|
|
return typedjson({ comparison: [] as ModelComparisonItem[], models: responseModels });
|
|
}
|
|
|
|
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
|
project.organizationId,
|
|
"standard"
|
|
);
|
|
const presenter = new ModelRegistryPresenter(clickhouse);
|
|
const now = new Date();
|
|
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
const comparison = await presenter.getModelComparison(responseModels, sevenDaysAgo, now);
|
|
|
|
return typedjson({ comparison, models: responseModels });
|
|
};
|
|
|
|
type ComparisonRow = {
|
|
label: string;
|
|
values: string[];
|
|
bestIndex?: number;
|
|
};
|
|
|
|
function buildRows(models: string[], comparison: ModelComparisonItem[]): ComparisonRow[] {
|
|
const dataMap = new Map<string, ModelComparisonItem>();
|
|
for (const item of comparison) {
|
|
dataMap.set(item.responseModel, item);
|
|
}
|
|
|
|
const getValue = (model: string, key: keyof ModelComparisonItem) => {
|
|
const d = dataMap.get(model);
|
|
return d ? d[key] : 0;
|
|
};
|
|
|
|
const findBest = (values: number[], lowerIsBetter: boolean) => {
|
|
if (values.every((v) => v === 0)) return undefined;
|
|
const filtered = values.map((v, i) => ({ v, i })).filter(({ v }) => v > 0);
|
|
if (filtered.length === 0) return undefined;
|
|
const best = lowerIsBetter
|
|
? filtered.reduce((a, b) => (a.v < b.v ? a : b))
|
|
: filtered.reduce((a, b) => (a.v > b.v ? a : b));
|
|
return best.i;
|
|
};
|
|
|
|
const callValues = models.map((m) => Number(getValue(m, "callCount")));
|
|
const ttfcP50Values = models.map((m) => Number(getValue(m, "ttfcP50")));
|
|
const ttfcP90Values = models.map((m) => Number(getValue(m, "ttfcP90")));
|
|
const tpsP50Values = models.map((m) => Number(getValue(m, "tpsP50")));
|
|
const tpsP90Values = models.map((m) => Number(getValue(m, "tpsP90")));
|
|
const costValues = models.map((m) => Number(getValue(m, "totalCost")));
|
|
|
|
return [
|
|
{
|
|
label: "Provider",
|
|
values: models.map((m) => dataMap.get(m)?.genAiSystem ?? "—"),
|
|
},
|
|
{
|
|
label: "Total Calls (7d)",
|
|
values: callValues.map((v) => formatNumberCompact(v)),
|
|
bestIndex: findBest(callValues, false),
|
|
},
|
|
{
|
|
label: "p50 TTFC",
|
|
values: ttfcP50Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")),
|
|
bestIndex: findBest(ttfcP50Values, true),
|
|
},
|
|
{
|
|
label: "p90 TTFC",
|
|
values: ttfcP90Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")),
|
|
bestIndex: findBest(ttfcP90Values, true),
|
|
},
|
|
{
|
|
label: "Tokens/sec (p50)",
|
|
values: tpsP50Values.map((v) => (v > 0 ? v.toFixed(0) : "—")),
|
|
bestIndex: findBest(tpsP50Values, false),
|
|
},
|
|
{
|
|
label: "Tokens/sec (p90)",
|
|
values: tpsP90Values.map((v) => (v > 0 ? v.toFixed(0) : "—")),
|
|
bestIndex: findBest(tpsP90Values, false),
|
|
},
|
|
{
|
|
label: "Total Cost (7d)",
|
|
values: costValues.map((v) => (v > 0 ? formatModelCost(v) : "—")),
|
|
bestIndex: findBest(costValues, true),
|
|
},
|
|
];
|
|
}
|
|
|
|
export default function ModelComparePage() {
|
|
const { comparison, models } = useTypedLoaderData<typeof loader>();
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
|
|
const rows = buildRows(models, comparison);
|
|
|
|
return (
|
|
<PageContainer>
|
|
<NavBar>
|
|
<PageTitle
|
|
title="Compare Models"
|
|
backButton={{
|
|
to: v3ModelsPath(organization, project, environment),
|
|
text: "Models",
|
|
}}
|
|
/>
|
|
</NavBar>
|
|
<PageBody scrollable>
|
|
{models.length < 2 ? (
|
|
<MainCenteredContainer className="max-w-md">
|
|
<InfoPanel
|
|
title="Compare models side by side"
|
|
icon={ArrowsRightLeftIcon}
|
|
iconClassName="text-indigo-500"
|
|
panelClassName="max-w-md"
|
|
>
|
|
<p className="text-sm text-text-dimmed">
|
|
Select 2-4 models from the catalog to compare their pricing, capabilities, and
|
|
performance metrics side by side.
|
|
</p>
|
|
<LinkButton
|
|
to={v3ModelsPath(organization, project, environment)}
|
|
variant="primary/small"
|
|
className="mt-3"
|
|
>
|
|
Browse models
|
|
</LinkButton>
|
|
</InfoPanel>
|
|
</MainCenteredContainer>
|
|
) : (
|
|
<div className="p-4">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>Metric</TableHeaderCell>
|
|
{models.map((model) => (
|
|
<TableHeaderCell key={model} alignment="right">
|
|
{model}
|
|
</TableHeaderCell>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rows.map((row) => (
|
|
<TableRow key={row.label}>
|
|
<TableCell>
|
|
<span className="text-xs font-medium text-text-dimmed">{row.label}</span>
|
|
</TableCell>
|
|
{row.values.map((value, i) => (
|
|
<TableCell key={i} alignment="right">
|
|
<span
|
|
className={`tabular-nums ${
|
|
row.bestIndex === i ? "font-medium text-success" : "text-text-bright"
|
|
}`}
|
|
>
|
|
{value}
|
|
</span>
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</PageBody>
|
|
</PageContainer>
|
|
);
|
|
}
|