Files
2026-07-13 13:32:57 +08:00

594 lines
20 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 { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import type { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import * as Property from "~/components/primitives/PropertyTable";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { ModelRegistryPresenter } from "~/presenters/v3/ModelRegistryPresenter.server";
import { MetricWidget } from "~/routes/resources.metric";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import {
formatFeature,
formatModelCost,
formatModelPrice,
formatProviderName,
formatTokenCount,
} from "~/utils/modelFormatters";
import { EnvironmentParamSchema, v3ModelComparePath, v3ModelsPath } from "~/utils/pathBuilder";
const ParamSchema = EnvironmentParamSchema.extend({
modelId: z.string(),
});
export const meta: MetaFunction = () => {
return [{ title: "Model Detail | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, modelId } = ParamSchema.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 clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new ModelRegistryPresenter(clickhouse);
const model = await presenter.getModelDetail(modelId);
if (!model) {
throw new Response("Model not found", { status: 404 });
}
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const userMetrics = await presenter.getUserMetrics(
model.modelName,
project.id,
environment.id,
sevenDaysAgo,
now
);
return typedjson({
model,
userMetrics,
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
});
};
/** Escape a value for safe interpolation into a TSQL single-quoted string. */
function escapeTSQL(value: string): string {
return value.replace(/'/g, "''");
}
function bignumberConfig(
column: string,
opts?: { aggregation?: "sum" | "avg" | "first"; suffix?: string; abbreviate?: boolean }
): QueryWidgetConfig {
return {
type: "bignumber",
column,
aggregation: opts?.aggregation ?? "sum",
abbreviate: opts?.abbreviate ?? false,
suffix: opts?.suffix,
};
}
function chartConfig(opts: {
chartType: "bar" | "line";
xAxisColumn: string;
yAxisColumns: string[];
aggregation?: "sum" | "avg";
}): QueryWidgetConfig {
return {
type: "chart",
chartType: opts.chartType,
xAxisColumn: opts.xAxisColumn,
yAxisColumns: opts.yAxisColumns,
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: opts.aggregation ?? "sum",
};
}
type Tab = "overview" | "global" | "usage";
const TAB_CONFIG: { id: Tab; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "usage", label: "Metrics" },
{ id: "global", label: "Global metrics" },
];
export default function ModelDetailPage() {
const { model, userMetrics, organizationId, projectId, environmentId } =
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const [activeTab, setActiveTab] = useState<Tab>("overview");
return (
<PageContainer>
<NavBar>
<PageTitle
title={model.displayId}
backButton={{
to: v3ModelsPath(organization, project, environment),
text: "Models",
}}
/>
<PageAccessories>
<LinkButton
variant="tertiary/small"
to={`${v3ModelComparePath(organization, project, environment)}?models=${model.modelName}`}
LeadingIcon={ArrowsRightLeftIcon}
>
Compare with...
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable>
<TabContainer>
{TAB_CONFIG.map((tab) => (
<TabButton
key={tab.id}
isActive={activeTab === tab.id}
layoutId="model-detail-tabs"
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</TabButton>
))}
</TabContainer>
<div className="p-4">
{activeTab === "overview" && <OverviewTab model={model} userMetrics={userMetrics} />}
{activeTab === "global" && (
<GlobalMetricsTab
modelName={model.modelName}
organizationId={organizationId}
projectId={projectId}
environmentId={environmentId}
/>
)}
{activeTab === "usage" && (
<YourUsageTab
modelName={model.modelName}
organizationId={organizationId}
projectId={projectId}
environmentId={environmentId}
/>
)}
</div>
</PageBody>
</PageContainer>
);
}
// --- Cost Estimator ---
function CostEstimator({
inputPrice,
outputPrice,
defaultInputTokens,
defaultOutputTokens,
}: {
inputPrice: number | null;
outputPrice: number | null;
defaultInputTokens?: number;
defaultOutputTokens?: number;
}) {
const [inputTokens, setInputTokens] = useState(defaultInputTokens ?? 1000);
const [outputTokens, setOutputTokens] = useState(defaultOutputTokens ?? 500);
const [numCalls, setNumCalls] = useState(1000);
if (inputPrice === null && outputPrice === null) return null;
const inputCost = inputTokens * (inputPrice ?? 0) * numCalls;
const outputCost = outputTokens * (outputPrice ?? 0) * numCalls;
const totalCost = inputCost + outputCost;
return (
<div className="rounded-md border border-grid-dimmed p-4">
<Header2 className="mb-3">Cost Estimator</Header2>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="input-tokens">Input tokens/call</Label>
<Input
id="input-tokens"
variant="medium"
fullWidth
type="number"
value={inputTokens}
onChange={(e) => setInputTokens(parseInt(e.target.value) || 0)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="output-tokens">Output tokens/call</Label>
<Input
id="output-tokens"
variant="medium"
fullWidth
type="number"
value={outputTokens}
onChange={(e) => setOutputTokens(parseInt(e.target.value) || 0)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="num-calls">Number of calls</Label>
<Input
id="num-calls"
variant="medium"
fullWidth
type="number"
value={numCalls}
onChange={(e) => setNumCalls(parseInt(e.target.value) || 0)}
/>
</div>
</div>
<Callout variant="info">
<div className="text-lg font-semibold tabular-nums text-text-bright">
{formatModelCost(totalCost)}
</div>
<div className="mt-1 space-y-0.5 text-xs tabular-nums text-text-dimmed">
<div>
Input: {formatModelCost(inputCost)} ({formatTokenCount(inputTokens * numCalls)} tokens
x {formatModelPrice(inputPrice)}/1M)
</div>
<div>
Output: {formatModelCost(outputCost)} ({formatTokenCount(outputTokens * numCalls)}{" "}
tokens x {formatModelPrice(outputPrice)}/1M)
</div>
</div>
</Callout>
</div>
</div>
);
}
// --- Overview Tab ---
function OverviewTab({
model,
userMetrics,
}: {
model: ReturnType<typeof useTypedLoaderData<typeof loader>>["model"];
userMetrics: ReturnType<typeof useTypedLoaderData<typeof loader>>["userMetrics"];
}) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Model Info */}
<div className="rounded-md border border-grid-dimmed p-4">
<Header2 className="mb-3">Model Info</Header2>
<Property.Table>
<Property.Item>
<Property.Label>Provider</Property.Label>
<Property.Value>{formatProviderName(model.provider)}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Model Name</Property.Label>
<Property.Value>
<InlineCode variant="small">{model.modelName}</InlineCode>
</Property.Value>
</Property.Item>
{model.description && (
<Property.Item>
<Property.Label>Description</Property.Label>
<Property.Value>{model.description}</Property.Value>
</Property.Item>
)}
{model.contextWindow && (
<Property.Item>
<Property.Label>Context Window</Property.Label>
<Property.Value>{formatTokenCount(model.contextWindow)} tokens</Property.Value>
</Property.Item>
)}
{model.maxOutputTokens && (
<Property.Item>
<Property.Label>Max Output</Property.Label>
<Property.Value>{formatTokenCount(model.maxOutputTokens)} tokens</Property.Value>
</Property.Item>
)}
{model.features.length > 0 && (
<Property.Item>
<Property.Label>Features</Property.Label>
<Property.Value>
<div className="flex flex-wrap gap-1">
{model.features.map((f) => (
<Badge key={f} variant="outline-rounded">
{formatFeature(f)}
</Badge>
))}
</div>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Match Pattern</Property.Label>
<Property.Value>
<InlineCode variant="small">{model.matchPattern}</InlineCode>
</Property.Value>
</Property.Item>
</Property.Table>
</div>
{/* Pricing */}
<div className="rounded-md border border-grid-dimmed p-4">
<Header2 className="mb-3">Pricing</Header2>
<Property.Table>
<Property.Item>
<Property.Label>Input</Property.Label>
<Property.Value>{formatModelPrice(model.inputPrice)} / 1M tokens</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Output</Property.Label>
<Property.Value>{formatModelPrice(model.outputPrice)} / 1M tokens</Property.Value>
</Property.Item>
</Property.Table>
{model.pricingTiers.length > 1 && (
<div className="mt-4">
<p className="mb-2 text-xs font-medium text-text-dimmed">All pricing tiers</p>
{model.pricingTiers.map((tier) => (
<div key={tier.name} className="mb-2 rounded border border-grid-dimmed p-2 text-xs">
<span className="font-medium text-text-bright">{tier.name}</span>
{tier.isDefault && (
<Badge variant="outline-rounded" className="ml-2">
default
</Badge>
)}
<div className="mt-1 space-y-0.5 text-text-dimmed">
{Object.entries(tier.prices).map(([usage, price]) => (
<div key={usage}>
{usage}: ${(price * 1_000_000).toFixed(4)} / 1M
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Cost Estimator */}
<CostEstimator
inputPrice={model.inputPrice}
outputPrice={model.outputPrice}
defaultInputTokens={
userMetrics.totalCalls > 0
? Math.round(userMetrics.totalInputTokens / userMetrics.totalCalls)
: undefined
}
defaultOutputTokens={
userMetrics.totalCalls > 0
? Math.round(userMetrics.totalOutputTokens / userMetrics.totalCalls)
: undefined
}
/>
</div>
);
}
// --- Global Metrics Tab ---
function GlobalMetricsTab({
modelName,
organizationId,
projectId,
environmentId,
}: {
modelName: string;
organizationId: string;
projectId: string;
environmentId: string;
}) {
const widgetProps = {
organizationId,
projectId,
environmentId,
scope: "environment" as const,
period: "7d",
from: null,
to: null,
};
return (
<div className="space-y-4">
{/* Big numbers */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-ttfc-p50`}
title="p50 TTFC"
query={`SELECT round(quantilesMerge(0.5)(ttfc_quantiles)[1], 0) AS ttfc_p50 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("ttfc_p50", { aggregation: "avg", suffix: "ms" })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-ttfc-p90`}
title="p90 TTFC"
query={`SELECT round(quantilesMerge(0.9)(ttfc_quantiles)[1], 0) AS ttfc_p90 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("ttfc_p90", { aggregation: "avg", suffix: "ms" })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-tps`}
title="Tokens/sec (p50)"
query={`SELECT round(quantilesMerge(0.5)(tps_quantiles)[1], 0) AS tps_p50 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("tps_p50", { aggregation: "avg" })}
{...widgetProps}
/>
</div>
</div>
{/* Charts */}
<div className="h-[300px]">
<MetricWidget
widgetKey={`${modelName}-ttfc-time`}
title="TTFC over time"
query={`SELECT timeBucket(), round(quantilesMerge(0.5)(ttfc_quantiles)[1], 0) AS ttfc_p50, round(quantilesMerge(0.9)(ttfc_quantiles)[1], 0) AS ttfc_p90 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY timeBucket ORDER BY timeBucket`}
config={chartConfig({
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["ttfc_p50", "ttfc_p90"],
aggregation: "avg",
})}
{...widgetProps}
/>
</div>
<Callout variant="info">
Aggregated across all Trigger.dev users. No tenant-specific data is exposed.
</Callout>
</div>
);
}
// --- Your Usage Tab ---
function YourUsageTab({
modelName,
organizationId,
projectId,
environmentId,
}: {
modelName: string;
organizationId: string;
projectId: string;
environmentId: string;
}) {
const widgetProps = {
organizationId,
projectId,
environmentId,
scope: "environment" as const,
period: "7d",
from: null,
to: null,
};
return (
<div className="space-y-4">
{/* Big numbers */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-user-calls`}
title="Total calls (7d)"
query={`SELECT count() AS total_calls FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("total_calls", { abbreviate: true })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-user-cost`}
title="Total cost (7d)"
query={`SELECT sum(total_cost) AS total_cost FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("total_cost", { aggregation: "sum" })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-user-ttfc`}
title="Avg TTFC"
query={`SELECT round(avg(ms_to_first_chunk), 0) AS avg_ttfc FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' AND ms_to_first_chunk > 0`}
config={bignumberConfig("avg_ttfc", { aggregation: "avg", suffix: "ms" })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-user-tps`}
title="Avg Tokens/sec"
query={`SELECT round(avg(tokens_per_second), 0) AS avg_tps FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' AND tokens_per_second > 0`}
config={bignumberConfig("avg_tps", { aggregation: "avg" })}
{...widgetProps}
/>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div className="h-[300px]">
<MetricWidget
widgetKey={`${modelName}-user-cost-time`}
title="Cost over time"
query={`SELECT timeBucket(), sum(total_cost) AS cost FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY timeBucket ORDER BY timeBucket`}
config={chartConfig({
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["cost"],
})}
{...widgetProps}
/>
</div>
<div className="h-[300px]">
<MetricWidget
widgetKey={`${modelName}-user-tokens-time`}
title="Tokens over time"
query={`SELECT timeBucket(), sum(input_tokens) AS input_tokens, sum(output_tokens) AS output_tokens FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY timeBucket ORDER BY timeBucket`}
config={chartConfig({
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["input_tokens", "output_tokens"],
})}
{...widgetProps}
/>
</div>
</div>
{/* Task breakdown */}
<div className="h-[300px]">
<MetricWidget
widgetKey={`${modelName}-user-tasks`}
title="Cost by task"
query={`SELECT task_identifier, count() AS calls, sum(total_cost) AS cost FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY task_identifier ORDER BY cost DESC LIMIT 20`}
config={{ type: "table", prettyFormatting: true, sorting: [] }}
{...widgetProps}
/>
</div>
</div>
);
}