Skip to content

Commit fbb7738

Browse files
committed
feat: add automatic LLM cost tracking for GenAI spans
Calculates costs from gen_ai.* span attributes using an in-memory pricing registry backed by Postgres, with model prices synced from Langfuse (145 models). Costs are dual-written to span attributes (trigger.llm.*) and a new llm_usage_v1 ClickHouse table for efficient aggregation. - New @internal/llm-pricing package with ModelPricingRegistry - Prisma schema for llm_models, llm_pricing_tiers, llm_prices - ClickHouse llm_usage_v1 table with DynamicFlushScheduler batching - Cost enrichment in enrichCreatableEvents() with gen_ai.usage.* extraction - TRQL llm_usage table schema for querying - Admin API endpoints for model CRUD, seed, and registry reload - Pill-style accessories on spans showing model, tokens, and cost - Anthropic logo icon for RunIcon - Style merge fix for partial/completed span deduplication - Env vars: LLM_COST_TRACKING_ENABLED, LLM_PRICING_RELOAD_INTERVAL_MS refs TRI-7773
1 parent 7672e8d commit fbb7738

33 files changed

+8821
-6
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Add automatic LLM cost calculation for spans with GenAI semantic conventions. When a span arrives with `gen_ai.response.model` and token usage data, costs are calculated from an in-memory pricing registry backed by Postgres and dual-written to both span attributes (`trigger.llm.*`) and a new `llm_usage_v1` ClickHouse table.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function AnthropicLogoIcon({ className }: { className?: string }) {
2+
return (
3+
<svg
4+
className={className}
5+
viewBox="0 0 24 24"
6+
fill="currentColor"
7+
xmlns="http://www.w3.org/2000/svg"
8+
>
9+
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
10+
</svg>
11+
);
12+
}

apps/webapp/app/components/runs/v3/RunIcon.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
TableCellsIcon,
88
TagIcon,
99
} from "@heroicons/react/20/solid";
10+
import { AnthropicLogoIcon } from "~/assets/icons/AnthropicLogoIcon";
1011
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
1112
import { TaskIcon } from "~/assets/icons/TaskIcon";
1213
import { cn } from "~/utils/cn";
@@ -112,6 +113,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
112113
return <FunctionIcon className={cn(className, "text-error")} />;
113114
case "streams":
114115
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
116+
case "tabler-brand-anthropic":
117+
return <AnthropicLogoIcon className={cn(className, "text-text-dimmed")} />;
115118
}
116119

117120
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;

apps/webapp/app/components/runs/v3/SpanTitle.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { TaskEventStyle } from "@trigger.dev/core/v3";
33
import type { TaskEventLevel } from "@trigger.dev/database";
44
import { Fragment } from "react";
55
import { cn } from "~/utils/cn";
6+
import { tablerIcons } from "~/utils/tablerIcons";
7+
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
68

79
type SpanTitleProps = {
810
message: string;
@@ -45,6 +47,15 @@ function SpanAccessory({
4547
/>
4648
);
4749
}
50+
case "pills": {
51+
return (
52+
<div className="flex items-center gap-1">
53+
{accessory.items.map((item, index) => (
54+
<SpanPill key={index} text={item.text} icon={item.icon} />
55+
))}
56+
</div>
57+
);
58+
}
4859
default: {
4960
return (
5061
<div className={cn("flex gap-1")}>
@@ -59,6 +70,21 @@ function SpanAccessory({
5970
}
6071
}
6172

73+
function SpanPill({ text, icon }: { text: string; icon?: string }) {
74+
const hasIcon = icon && tablerIcons.has(icon);
75+
76+
return (
77+
<span className="inline-flex items-center gap-0.5 rounded-full border border-charcoal-700 bg-charcoal-850 px-1.5 py-px text-xxs text-text-dimmed">
78+
{hasIcon && (
79+
<svg className="size-3 stroke-[1.5] text-text-dimmed/70">
80+
<use xlinkHref={`${tablerSpritePath}#${icon}`} />
81+
</svg>
82+
)}
83+
<span className="truncate">{text}</span>
84+
</span>
85+
);
86+
}
87+
6288
export function SpanCodePathAccessory({
6389
accessory,
6490
className,

apps/webapp/app/env.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,6 +1277,10 @@ const EnvironmentSchema = z
12771277
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
12781278
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
12791279

1280+
// LLM cost tracking
1281+
LLM_COST_TRACKING_ENABLED: BoolEnv.default(true),
1282+
LLM_PRICING_RELOAD_INTERVAL_MS: z.coerce.number().int().default(5 * 60 * 1000), // 5 minutes
1283+
12801284
// Bootstrap
12811285
TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"),
12821286
TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(),
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { prisma } from "~/db.server";
4+
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
5+
6+
async function requireAdmin(request: Request) {
7+
const authResult = await authenticateApiRequestWithPersonalAccessToken(request);
8+
if (!authResult) {
9+
throw json({ error: "Invalid or Missing API key" }, { status: 401 });
10+
}
11+
12+
const user = await prisma.user.findUnique({ where: { id: authResult.userId } });
13+
if (!user?.admin) {
14+
throw json({ error: "You must be an admin to perform this action" }, { status: 403 });
15+
}
16+
17+
return user;
18+
}
19+
20+
export async function loader({ request, params }: LoaderFunctionArgs) {
21+
await requireAdmin(request);
22+
23+
const model = await prisma.llmModel.findUnique({
24+
where: { id: params.modelId },
25+
include: {
26+
pricingTiers: {
27+
include: { prices: true },
28+
orderBy: { priority: "asc" },
29+
},
30+
},
31+
});
32+
33+
if (!model) {
34+
return json({ error: "Model not found" }, { status: 404 });
35+
}
36+
37+
return json({ model });
38+
}
39+
40+
const UpdateModelSchema = z.object({
41+
modelName: z.string().min(1).optional(),
42+
matchPattern: z.string().min(1).optional(),
43+
startDate: z.string().nullable().optional(),
44+
pricingTiers: z
45+
.array(
46+
z.object({
47+
name: z.string().min(1),
48+
isDefault: z.boolean().default(true),
49+
priority: z.number().int().default(0),
50+
conditions: z
51+
.array(
52+
z.object({
53+
usageDetailPattern: z.string(),
54+
operator: z.enum(["gt", "gte", "lt", "lte", "eq", "neq"]),
55+
value: z.number(),
56+
})
57+
)
58+
.default([]),
59+
prices: z.record(z.string(), z.number()),
60+
})
61+
)
62+
.optional(),
63+
});
64+
65+
export async function action({ request, params }: ActionFunctionArgs) {
66+
await requireAdmin(request);
67+
68+
const modelId = params.modelId!;
69+
70+
if (request.method === "DELETE") {
71+
const existing = await prisma.llmModel.findUnique({ where: { id: modelId } });
72+
if (!existing) {
73+
return json({ error: "Model not found" }, { status: 404 });
74+
}
75+
76+
await prisma.llmModel.delete({ where: { id: modelId } });
77+
return json({ success: true });
78+
}
79+
80+
if (request.method !== "PUT") {
81+
return json({ error: "Method not allowed" }, { status: 405 });
82+
}
83+
84+
const body = await request.json();
85+
const parsed = UpdateModelSchema.safeParse(body);
86+
87+
if (!parsed.success) {
88+
return json({ error: "Invalid request body", details: parsed.error.issues }, { status: 400 });
89+
}
90+
91+
const { modelName, matchPattern, startDate, pricingTiers } = parsed.data;
92+
93+
// Validate regex if provided
94+
if (matchPattern) {
95+
try {
96+
new RegExp(matchPattern);
97+
} catch {
98+
return json({ error: "Invalid regex in matchPattern" }, { status: 400 });
99+
}
100+
}
101+
102+
// Update model fields
103+
const model = await prisma.llmModel.update({
104+
where: { id: modelId },
105+
data: {
106+
...(modelName !== undefined && { modelName }),
107+
...(matchPattern !== undefined && { matchPattern }),
108+
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
109+
},
110+
});
111+
112+
// If pricing tiers provided, replace them entirely
113+
if (pricingTiers) {
114+
// Delete existing tiers (cascades to prices)
115+
await prisma.llmPricingTier.deleteMany({ where: { modelId } });
116+
117+
// Create new tiers
118+
for (const tier of pricingTiers) {
119+
await prisma.llmPricingTier.create({
120+
data: {
121+
modelId,
122+
name: tier.name,
123+
isDefault: tier.isDefault,
124+
priority: tier.priority,
125+
conditions: tier.conditions,
126+
prices: {
127+
create: Object.entries(tier.prices).map(([usageType, price]) => ({
128+
modelId,
129+
usageType,
130+
price,
131+
})),
132+
},
133+
},
134+
});
135+
}
136+
}
137+
138+
const updated = await prisma.llmModel.findUnique({
139+
where: { id: modelId },
140+
include: {
141+
pricingTiers: {
142+
include: { prices: true },
143+
orderBy: { priority: "asc" },
144+
},
145+
},
146+
});
147+
148+
return json({ model: updated });
149+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { prisma } from "~/db.server";
3+
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
4+
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
5+
6+
export async function action({ request }: ActionFunctionArgs) {
7+
const authResult = await authenticateApiRequestWithPersonalAccessToken(request);
8+
if (!authResult) {
9+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
10+
}
11+
12+
const user = await prisma.user.findUnique({ where: { id: authResult.userId } });
13+
if (!user?.admin) {
14+
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
15+
}
16+
17+
if (!llmPricingRegistry) {
18+
return json({ error: "LLM cost tracking is disabled" }, { status: 400 });
19+
}
20+
21+
await llmPricingRegistry.reload();
22+
23+
return json({ success: true, message: "LLM pricing registry reloaded" });
24+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { seedLlmPricing } from "@internal/llm-pricing";
3+
import { prisma } from "~/db.server";
4+
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
5+
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
6+
7+
export async function action({ request }: ActionFunctionArgs) {
8+
const authResult = await authenticateApiRequestWithPersonalAccessToken(request);
9+
if (!authResult) {
10+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
11+
}
12+
13+
const user = await prisma.user.findUnique({ where: { id: authResult.userId } });
14+
if (!user?.admin) {
15+
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
16+
}
17+
18+
const result = await seedLlmPricing(prisma);
19+
20+
// Reload the in-memory registry after seeding (if enabled)
21+
if (llmPricingRegistry) {
22+
await llmPricingRegistry.reload();
23+
}
24+
25+
return json({
26+
success: true,
27+
...result,
28+
message: `Seeded ${result.modelsCreated} models, skipped ${result.modelsSkipped} existing`,
29+
});
30+
}

0 commit comments

Comments
 (0)