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}
-
- )}
-
-
- ))}
+ );
+ })}
)}