diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index fdb31045..a9011f21 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -1,6 +1,6 @@ import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm"; import type { Db } from "@paperclip/db"; -import { agents, companies, costEvents } from "@paperclip/db"; +import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclip/db"; import { notFound, unprocessable } from "../errors.js"; export interface CostDateRange { @@ -122,24 +122,51 @@ export function costService(db: Db) { }, byProject: async (companyId: string, range?: CostDateRange) => { - const conditions: ReturnType[] = [ - eq(costEvents.companyId, companyId), - isNotNull(costEvents.projectId), - ]; - if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); - if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); + const issueIdAsText = sql`${issues.id}::text`; + const runProjectLinks = db + .selectDistinctOn([activityLog.runId, issues.projectId], { + runId: sql`${activityLog.runId}`, + projectId: sql`${issues.projectId}`, + }) + .from(activityLog) + .innerJoin( + issues, + and( + eq(activityLog.entityType, "issue"), + eq(activityLog.entityId, issueIdAsText), + ), + ) + .where( + and( + eq(activityLog.companyId, companyId), + eq(issues.companyId, companyId), + isNotNull(activityLog.runId), + isNotNull(issues.projectId), + ), + ) + .orderBy(activityLog.runId, issues.projectId, desc(activityLog.createdAt)) + .as("run_project_links"); + + const conditions: ReturnType[] = [eq(heartbeatRuns.companyId, companyId)]; + if (range?.from) conditions.push(gte(heartbeatRuns.finishedAt, range.from)); + if (range?.to) conditions.push(lte(heartbeatRuns.finishedAt, range.to)); + + const costCentsExpr = sql`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`; return db .select({ - projectId: costEvents.projectId, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + projectId: runProjectLinks.projectId, + projectName: projects.name, + costCents: costCentsExpr, + inputTokens: sql`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0)), 0)::int`, + outputTokens: sql`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0)), 0)::int`, }) - .from(costEvents) + .from(runProjectLinks) + .innerJoin(heartbeatRuns, eq(runProjectLinks.runId, heartbeatRuns.id)) + .innerJoin(projects, eq(runProjectLinks.projectId, projects.id)) .where(and(...conditions)) - .groupBy(costEvents.projectId) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .groupBy(runProjectLinks.projectId, projects.name) + .orderBy(desc(costCentsExpr)); }, }; } diff --git a/ui/src/api/costs.ts b/ui/src/api/costs.ts index bb90fd49..1187e4d0 100644 --- a/ui/src/api/costs.ts +++ b/ui/src/api/costs.ts @@ -1,9 +1,9 @@ import type { CostSummary, CostByAgent } from "@paperclip/shared"; import { api } from "./client"; -export interface CostByEntity { - agentId?: string | null; - projectId?: string | null; +export interface CostByProject { + projectId: string | null; + projectName: string | null; costCents: number; inputTokens: number; outputTokens: number; @@ -23,5 +23,5 @@ export const costsApi = { byAgent: (companyId: string, from?: string, to?: string) => api.get(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`), byProject: (companyId: string, from?: string, to?: string) => - api.get(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`), + api.get(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`), }; diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index e39d6150..26f10c81 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -202,16 +202,16 @@ export function Costs() {

By Project

{data.byProject.length === 0 ? ( -

No project-attributed costs yet.

+

No project-attributed run costs yet.

) : (
- {data.byProject.map((row, idx) => ( + {data.byProject.map((row) => (
- - {row.projectId ?? "Unattributed"} + + {row.projectName ?? row.projectId ?? "Unattributed"} {formatCents(row.costCents)}