From 9d213806990c4f3d4f5080807f76dc622e5afbb7 Mon Sep 17 00:00:00 2001 From: Sai Shankar Date: Sun, 8 Mar 2026 20:56:13 +0530 Subject: [PATCH] feat(costs): add agent model breakdown, harden date validation, sync CostByProject type, fix quota threshold and tab-gated queries - add byAgentModel endpoint and expandable per-agent model sub-rows in the spend tab - validate date range inputs with isNaN + badRequest to return HTTP 400 on bad input - move CostByProject from a local api/costs.ts definition into packages/shared types - gate providerData query on mainTab === providers, consistent with weekData/windowData/quotaData - fix byProject range filter from finishedAt to startedAt, consistent with byProvider runs query - fix WHAM used_percent threshold from <= 1 to < 1 to avoid misclassifying 1% usage as 100% - replace inline opacity style with tailwind bg-primary/85 class in ProviderQuotaCard - reset expandedAgents set when company or date range changes - sort agent model sub-rows by cost descending in ui memo --- packages/shared/src/index.ts | 2 + packages/shared/src/types/cost.ts | 20 ++++ packages/shared/src/types/index.ts | 2 +- server/src/routes/costs.ts | 17 ++- server/src/services/costs.ts | 30 ++++- server/src/services/quota-windows.ts | 10 +- ui/src/api/costs.ts | 12 +- ui/src/components/ProviderQuotaCard.tsx | 8 +- ui/src/pages/Costs.tsx | 147 ++++++++++++++++++------ 9 files changed, 192 insertions(+), 56 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a8df3802..a5015862 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -133,7 +133,9 @@ export type { CostSummary, CostByAgent, CostByProviderModel, + CostByAgentModel, CostWindowSpendRow, + CostByProject, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState, diff --git a/packages/shared/src/types/cost.ts b/packages/shared/src/types/cost.ts index 7480c03b..af2ba0e1 100644 --- a/packages/shared/src/types/cost.ts +++ b/packages/shared/src/types/cost.ts @@ -47,6 +47,17 @@ export interface CostByProviderModel { subscriptionOutputTokens: number; } +/** per-agent breakdown by provider + model, for identifying token-hungry agents */ +export interface CostByAgentModel { + agentId: string; + agentName: string | null; + provider: string; + model: string; + costCents: number; + inputTokens: number; + outputTokens: number; +} + /** spend per provider for a fixed rolling time window */ export interface CostWindowSpendRow { provider: string; @@ -58,3 +69,12 @@ export interface CostWindowSpendRow { inputTokens: number; outputTokens: number; } + +/** cost attributed to a project via heartbeat run → activity log → issue → project chain */ +export interface CostByProject { + projectId: string | null; + projectName: string | null; + costCents: number; + inputTokens: number; + outputTokens: number; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 1564614c..135c0d14 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -46,7 +46,7 @@ export type { CompanySecret, SecretProviderDescriptor, } from "./secrets.js"; -export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow } from "./cost.js"; +export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js"; export type { HeartbeatRun, HeartbeatRunEvent, diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index 91da4aae..51bee69d 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -5,6 +5,7 @@ import { validate } from "../middleware/validate.js"; import { costService, companyService, agentService, logActivity } from "../services/index.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { fetchAllQuotaWindows } from "../services/quota-windows.js"; +import { badRequest } from "../errors.js"; export function costRoutes(db: Db) { const router = Router(); @@ -42,8 +43,12 @@ export function costRoutes(db: Db) { }); function parseDateRange(query: Record) { - const from = query.from ? new Date(query.from as string) : undefined; - const to = query.to ? new Date(query.to as string) : undefined; + const fromRaw = query.from as string | undefined; + const toRaw = query.to as string | undefined; + const from = fromRaw ? new Date(fromRaw) : undefined; + const to = toRaw ? new Date(toRaw) : undefined; + if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date"); + if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date"); return (from || to) ? { from, to } : undefined; } @@ -63,6 +68,14 @@ export function costRoutes(db: Db) { res.json(rows); }); + router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const range = parseDateRange(req.query); + const rows = await costs.byAgentModel(companyId, range); + res.json(rows); + }); + router.get("/companies/:companyId/costs/by-provider", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 1dd46fe0..ea5fa8a8 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -268,6 +268,32 @@ export function costService(db: Db) { return results.flat(); }, + byAgentModel: async (companyId: string, range?: CostDateRange) => { + const conditions: ReturnType[] = [eq(costEvents.companyId, companyId)]; + if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); + if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); + + // single query: group by agent + provider + model. + // the (companyId, agentId, occurredAt) composite index covers this well. + // order by provider + model for stable db-level ordering; cost-desc sort + // within each agent's sub-rows is done client-side in the ui memo. + return db + .select({ + agentId: costEvents.agentId, + agentName: agents.name, + provider: costEvents.provider, + model: costEvents.model, + costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, + outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + }) + .from(costEvents) + .leftJoin(agents, eq(costEvents.agentId, agents.id)) + .where(and(...conditions)) + .groupBy(costEvents.agentId, agents.name, costEvents.provider, costEvents.model) + .orderBy(costEvents.provider, costEvents.model); + }, + byProject: async (companyId: string, range?: CostDateRange) => { const issueIdAsText = sql`${issues.id}::text`; const runProjectLinks = db @@ -295,8 +321,8 @@ export function costService(db: Db) { .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)); + if (range?.from) conditions.push(gte(heartbeatRuns.startedAt, range.from)); + if (range?.to) conditions.push(lte(heartbeatRuns.startedAt, range.to)); const costCentsExpr = sql`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`; diff --git a/server/src/services/quota-windows.ts b/server/src/services/quota-windows.ts index b9a67b0f..0550a81c 100644 --- a/server/src/services/quota-windows.ts +++ b/server/src/services/quota-windows.ts @@ -188,10 +188,11 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise const rateLimit = body.rate_limit; if (rateLimit?.primary_window != null) { const w = rateLimit.primary_window; - // wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case + // wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case. + // use < 1 (not <= 1) so that 1% usage (rawPct=1) is not misclassified as 100%. const rawPct = w.used_percent ?? null; const usedPercent = rawPct != null - ? Math.min(100, Math.round(rawPct <= 1 ? rawPct * 100 : rawPct)) + ? Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct)) : null; windows.push({ label: secondsToWindowLabel(w.limit_window_seconds, "Primary"), @@ -202,10 +203,11 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise } if (rateLimit?.secondary_window != null) { const w = rateLimit.secondary_window; - // wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case + // wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case. + // use < 1 (not <= 1) so that 1% usage (rawPct=1) is not misclassified as 100%. const rawPct = w.used_percent ?? null; const usedPercent = rawPct != null - ? Math.min(100, Math.round(rawPct <= 1 ? rawPct * 100 : rawPct)) + ? Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct)) : null; windows.push({ label: secondsToWindowLabel(w.limit_window_seconds, "Secondary"), diff --git a/ui/src/api/costs.ts b/ui/src/api/costs.ts index ba515934..104a1e56 100644 --- a/ui/src/api/costs.ts +++ b/ui/src/api/costs.ts @@ -1,14 +1,6 @@ -import type { CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow, ProviderQuotaResult } from "@paperclipai/shared"; +import type { CostSummary, CostByAgent, CostByProviderModel, CostByAgentModel, CostByProject, CostWindowSpendRow, ProviderQuotaResult } from "@paperclipai/shared"; import { api } from "./client"; -export interface CostByProject { - projectId: string | null; - projectName: string | null; - costCents: number; - inputTokens: number; - outputTokens: number; -} - function dateParams(from?: string, to?: string): string { const params = new URLSearchParams(); if (from) params.set("from", from); @@ -22,6 +14,8 @@ export const costsApi = { api.get(`/companies/${companyId}/costs/summary${dateParams(from, to)}`), byAgent: (companyId: string, from?: string, to?: string) => api.get(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`), + byAgentModel: (companyId: string, from?: string, to?: string) => + api.get(`/companies/${companyId}/costs/by-agent-model${dateParams(from, to)}`), byProject: (companyId: string, from?: string, to?: string) => api.get(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`), byProvider: (companyId: string, from?: string, to?: string) => diff --git a/ui/src/components/ProviderQuotaCard.tsx b/ui/src/components/ProviderQuotaCard.tsx index e4d34db5..a710d358 100644 --- a/ui/src/components/ProviderQuotaCard.tsx +++ b/ui/src/components/ProviderQuotaCard.tsx @@ -174,7 +174,7 @@ export function ProviderQuotaCard({ Subscription quota

- {quotaWindows.map((qw, i) => { + {quotaWindows.map((qw) => { const fillColor = qw.usedPercent == null ? null @@ -184,7 +184,7 @@ export function ProviderQuotaCard({ ? "bg-yellow-400" : "bg-green-400"; return ( -
+
{qw.label} @@ -279,8 +279,8 @@ export function ProviderQuotaCard({ /> {/* cost share overlay — narrower, opaque, shows relative cost weight */}
diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index c2ed2524..e63acc01 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared"; +import type { CostByAgentModel, CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared"; import { costsApi } from "../api/costs"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -15,7 +15,7 @@ import { StatusBadge } from "../components/StatusBadge"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { DollarSign } from "lucide-react"; +import { DollarSign, ChevronDown, ChevronRight } from "lucide-react"; import { useDateRange, PRESET_LABELS, PRESET_KEYS } from "../hooks/useDateRange"; // sentinel used in query keys when no company is selected, to avoid polluting the cache @@ -97,22 +97,54 @@ export function Costs() { const { data: spendData, isLoading: spendLoading, error: spendError } = useQuery({ queryKey: queryKeys.costs(companyId, from || undefined, to || undefined), queryFn: async () => { - const [summary, byAgent, byProject] = await Promise.all([ + const [summary, byAgent, byProject, byAgentModel] = await Promise.all([ costsApi.summary(companyId, from || undefined, to || undefined), costsApi.byAgent(companyId, from || undefined, to || undefined), costsApi.byProject(companyId, from || undefined, to || undefined), + costsApi.byAgentModel(companyId, from || undefined, to || undefined), ]); - return { summary, byAgent, byProject }; + return { summary, byAgent, byProject, byAgentModel }; }, enabled: !!selectedCompanyId && customReady, }); + // tracks which agent rows are expanded in the By Agent card. + // reset whenever the date range or company changes so stale open-states + // from a previous query window don't bleed into the new result set. + const [expandedAgents, setExpandedAgents] = useState>(new Set()); + useEffect(() => { + setExpandedAgents(new Set()); + }, [companyId, from, to]); + function toggleAgent(agentId: string) { + setExpandedAgents((prev) => { + const next = new Set(prev); + if (next.has(agentId)) next.delete(agentId); + else next.add(agentId); + return next; + }); + } + + // group byAgentModel rows by agentId for O(1) lookup in the render pass. + // sub-rows are sorted by cost descending so the most expensive model is first. + const agentModelRows = useMemo(() => { + const map = new Map(); + for (const row of spendData?.byAgentModel ?? []) { + const arr = map.get(row.agentId) ?? []; + arr.push(row); + map.set(row.agentId, arr); + } + for (const [id, rows] of map) { + map.set(id, rows.slice().sort((a, b) => b.costCents - a.costCents)); + } + return map; + }, [spendData?.byAgentModel]); + // ---------- providers tab queries (polling — provider quota changes during agent runs) ---------- const { data: providerData } = useQuery({ queryKey: queryKeys.usageByProvider(companyId, from || undefined, to || undefined), queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined), - enabled: !!selectedCompanyId && customReady, + enabled: !!selectedCompanyId && customReady && mainTab === "providers", refetchInterval: 30_000, staleTime: 10_000, }); @@ -120,7 +152,7 @@ export function Costs() { const { data: weekData } = useQuery({ queryKey: queryKeys.usageByProvider(companyId, weekRange.from, weekRange.to), queryFn: () => costsApi.byProvider(companyId, weekRange.from, weekRange.to), - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && mainTab === "providers", refetchInterval: 30_000, staleTime: 10_000, }); @@ -128,7 +160,9 @@ export function Costs() { const { data: windowData } = useQuery({ queryKey: queryKeys.usageWindowSpend(companyId), queryFn: () => costsApi.windowSpend(companyId), - enabled: !!selectedCompanyId, + // only fetch when the providers tab is active — these queries trigger outbound + // network calls to provider quota apis; no need to run them on the spend tab. + enabled: !!selectedCompanyId && mainTab === "providers", refetchInterval: 30_000, staleTime: 10_000, }); @@ -136,7 +170,7 @@ export function Costs() { const { data: quotaData } = useQuery({ queryKey: queryKeys.usageQuotaWindows(companyId), queryFn: () => costsApi.quotaWindows(companyId), - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && mainTab === "providers", // quota windows come from external provider apis; refresh every 5 minutes refetchInterval: 300_000, staleTime: 60_000, @@ -362,34 +396,79 @@ export function Costs() {

No cost events yet.

) : (
- {spendData.byAgent.map((row) => ( -
-
- - {row.agentStatus === "terminated" && ( - + {spendData.byAgent.map((row) => { + const modelRows = agentModelRows.get(row.agentId) ?? []; + const isExpanded = expandedAgents.has(row.agentId); + const hasBreakdown = modelRows.length > 0; + return ( +
+
hasBreakdown && toggleAgent(row.agentId)} + > +
+ {hasBreakdown ? ( + isExpanded + ? + : + ) : ( + + )} + + {row.agentStatus === "terminated" && ( + + )} +
+
+ {formatCents(row.costCents)} + + in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok + + {(row.apiRunCount > 0 || row.subscriptionRunCount > 0) && ( + + {row.apiRunCount > 0 ? `api runs: ${row.apiRunCount}` : null} + {row.apiRunCount > 0 && row.subscriptionRunCount > 0 ? " | " : null} + {row.subscriptionRunCount > 0 + ? `subscription runs: ${row.subscriptionRunCount} (${formatTokens(row.subscriptionInputTokens)} in / ${formatTokens(row.subscriptionOutputTokens)} out tok)` + : null} + + )} +
+
+ {isExpanded && modelRows.length > 0 && ( +
+ {modelRows.map((m) => { + const totalAgentCents = row.costCents; + const sharePct = totalAgentCents > 0 + ? Math.round((m.costCents / totalAgentCents) * 100) + : 0; + return ( +
+
+ {providerDisplayName(m.provider)} + / + {m.model} +
+
+ + {formatCents(m.costCents)} + ({sharePct}%) + + + in {formatTokens(m.inputTokens)} / out {formatTokens(m.outputTokens)} tok + +
+
+ ); + })} +
)}
-
- {formatCents(row.costCents)} - - in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok - - {(row.apiRunCount > 0 || row.subscriptionRunCount > 0) && ( - - {row.apiRunCount > 0 ? `api runs: ${row.apiRunCount}` : null} - {row.apiRunCount > 0 && row.subscriptionRunCount > 0 ? " | " : null} - {row.subscriptionRunCount > 0 - ? `subscription runs: ${row.subscriptionRunCount} (${formatTokens(row.subscriptionInputTokens)} in / ${formatTokens(row.subscriptionOutputTokens)} out tok)` - : null} - - )} -
-
- ))} + ); + })}
)}