feat(costs): add billing, quota, and budget control plane

This commit is contained in:
Dotta
2026-03-14 22:00:12 -05:00
parent 656b4659fc
commit 76e6cc08a6
91 changed files with 22406 additions and 769 deletions

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { ApiError } from "../api/client";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
@@ -24,8 +25,9 @@ import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
@@ -58,7 +60,15 @@ import {
import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
import {
isUuidLike,
type Agent,
type BudgetPolicySummary,
type HeartbeatRun,
type HeartbeatRunEvent,
type AgentRuntimeState,
type LiveEvent,
} from "@paperclipai/shared";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
@@ -204,8 +214,7 @@ function runMetrics(run: HeartbeatRun) {
"cache_read_input_tokens",
);
const cost =
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
visibleRunCostUsd(usage, result);
return {
input,
output,
@@ -294,11 +303,50 @@ export function AgentDetail() {
enabled: !!resolvedCompanyId,
});
const { data: budgetOverview } = useQuery({
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
enabled: !!resolvedCompanyId,
refetchInterval: 30_000,
staleTime: 5_000,
});
const assignedIssues = (allIssues ?? [])
.filter((i) => i.assigneeAgentId === agent?.id)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
const agentBudgetSummary = useMemo(() => {
const matched = budgetOverview?.policies.find(
(policy) => policy.scopeType === "agent" && policy.scopeId === (agent?.id ?? routeAgentRef),
);
if (matched) return matched;
const budgetMonthlyCents = agent?.budgetMonthlyCents ?? 0;
const spentMonthlyCents = agent?.spentMonthlyCents ?? 0;
return {
policyId: "",
companyId: resolvedCompanyId ?? "",
scopeType: "agent",
scopeId: agent?.id ?? routeAgentRef,
scopeName: agent?.name ?? "Agent",
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: budgetMonthlyCents,
observedAmount: spentMonthlyCents,
remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents),
utilizationPercent:
budgetMonthlyCents > 0 ? Number(((spentMonthlyCents / budgetMonthlyCents) * 100).toFixed(2)) : 0,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: budgetMonthlyCents > 0,
status: budgetMonthlyCents > 0 && spentMonthlyCents >= budgetMonthlyCents ? "hard_stop" : "ok",
paused: agent?.status === "paused",
pauseReason: agent?.pauseReason ?? null,
windowStart: new Date(),
windowEnd: new Date(),
} satisfies BudgetPolicySummary;
}, [agent, budgetOverview?.policies, resolvedCompanyId, routeAgentRef]);
const mobileLiveRun = useMemo(
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
[heartbeats],
@@ -360,6 +408,24 @@ export function AgentDetail() {
},
});
const budgetMutation = useMutation({
mutationFn: (amount: number) =>
budgetsApi.upsertPolicy(resolvedCompanyId!, {
scopeType: "agent",
scopeId: agent?.id ?? routeAgentRef,
amount,
windowKind: "calendar_month_utc",
}),
onSuccess: () => {
if (!resolvedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
},
});
const updateIcon = useMutation({
mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined),
onSuccess: () => {
@@ -579,6 +645,15 @@ export function AgentDetail() {
</Tabs>
)}
{!urlRunId && resolvedCompanyId ? (
<BudgetPolicyCard
summary={agentBudgetSummary}
isSaving={budgetMutation.isPending}
compact
onSave={(amount) => budgetMutation.mutate(amount)}
/>
) : null}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{isPendingApproval && (
<p className="text-sm text-amber-500">
@@ -849,8 +924,8 @@ function CostsSection({
}) {
const runsWithCost = runs
.filter((r) => {
const u = r.usageJson as Record<string, unknown> | null;
return u && (u.cost_usd || u.total_cost_usd || u.input_tokens);
const metrics = runMetrics(r);
return metrics.cost > 0 || metrics.input > 0 || metrics.output > 0 || metrics.cached > 0;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -892,16 +967,16 @@ function CostsSection({
</thead>
<tbody>
{runsWithCost.slice(0, 10).map((run) => {
const u = run.usageJson as Record<string, unknown>;
const metrics = runMetrics(run);
return (
<tr key={run.id} className="border-b border-border last:border-b-0">
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.input_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.output_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.input)}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.output)}</td>
<td className="px-3 py-2 text-right tabular-nums">
{(u.cost_usd || u.total_cost_usd)
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
{metrics.cost > 0
? `$${metrics.cost.toFixed(4)}`
: "-"
}
</td>

View File

@@ -147,6 +147,7 @@ export function ApprovalDetail() {
const payload = approval.payload as Record<string, unknown>;
const linkedAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
const isActionable = approval.status === "pending" || approval.status === "revision_requested";
const isBudgetApproval = approval.type === "budget_override_required";
const TypeIcon = typeIcon[approval.type] ?? defaultTypeIcon;
const showApprovedBanner = searchParams.get("resolved") === "approved" && approval.status === "approved";
const primaryLinkedIssue = linkedIssues?.[0] ?? null;
@@ -260,7 +261,7 @@ export function ApprovalDetail() {
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{isActionable && (
{isActionable && !isBudgetApproval && (
<>
<Button
size="sm"
@@ -280,6 +281,11 @@ export function ApprovalDetail() {
</Button>
</>
)}
{isBudgetApproval && approval.status === "pending" && (
<p className="text-sm text-muted-foreground">
Resolve this budget stop from the budget controls on <Link to="/costs" className="underline underline-offset-2">/costs</Link>.
</p>
)}
{approval.status === "pending" && (
<Button
size="sm"

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ import { ActivityRow } from "../components/ActivityRow";
import { Identity } from "../components/Identity";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatCents } from "../lib/utils";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle } from "lucide-react";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { PageSkeleton } from "../components/PageSkeleton";
@@ -210,6 +210,25 @@ export function Dashboard() {
{data && (
<>
{data.budgets.activeIncidents > 0 ? (
<div className="flex items-start justify-between gap-3 rounded-xl border border-red-500/20 bg-[linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))] px-4 py-3">
<div className="flex items-start gap-2.5">
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-300" />
<div>
<p className="text-sm font-medium text-red-50">
{data.budgets.activeIncidents} active budget incident{data.budgets.activeIncidents === 1 ? "" : "s"}
</p>
<p className="text-xs text-red-100/70">
{data.budgets.pausedAgents} agents paused · {data.budgets.pausedProjects} projects paused · {data.budgets.pendingApprovals} pending budget approvals
</p>
</div>
</div>
<Link to="/costs" className="text-sm underline underline-offset-2 text-red-100">
Open budgets
</Link>
</div>
) : null}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-1 sm:gap-2">
<MetricCard
icon={Bot}
@@ -251,12 +270,14 @@ export function Dashboard() {
/>
<MetricCard
icon={ShieldCheck}
value={data.pendingApprovals}
value={data.pendingApprovals + data.budgets.pendingApprovals}
label="Pending Approvals"
to="/approvals"
description={
<span>
Awaiting board review
{data.budgets.pendingApprovals > 0
? `${data.budgets.pendingApprovals} budget overrides awaiting board review`
: "Awaiting board review"}
</span>
}
/>

View File

@@ -13,7 +13,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens } from "../lib/utils";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor";
import { CommentThread } from "../components/CommentThread";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
@@ -417,9 +417,7 @@ export function IssueDetail() {
"cached_input_tokens",
"cache_read_input_tokens",
);
const runCost =
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
const runCost = visibleRunCostUsd(usage, result);
if (runCost > 0) hasCost = true;
if (runInput + runOutput + runCached > 0) hasTokens = true;
input += runInput;

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PROJECT_COLORS, isUuidLike } from "@paperclipai/shared";
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
import { budgetsApi } from "../api/budgets";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
@@ -14,6 +15,7 @@ import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
@@ -296,6 +298,14 @@ export function ProjectDetail() {
},
});
const { data: budgetOverview } = useQuery({
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
enabled: !!resolvedCompanyId,
refetchInterval: 30_000,
staleTime: 5_000,
});
useEffect(() => {
setBreadcrumbs([
{ label: "Projects", href: "/projects" },
@@ -377,6 +387,53 @@ export function ProjectDetail() {
}
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
const projectBudgetSummary = useMemo(() => {
const matched = budgetOverview?.policies.find(
(policy) => policy.scopeType === "project" && policy.scopeId === (project?.id ?? routeProjectRef),
);
if (matched) return matched;
return {
policyId: "",
companyId: resolvedCompanyId ?? "",
scopeType: "project",
scopeId: project?.id ?? routeProjectRef,
scopeName: project?.name ?? "Project",
metric: "billed_cents",
windowKind: "lifetime",
amount: 0,
observedAmount: 0,
remainingAmount: 0,
utilizationPercent: 0,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: false,
status: "ok",
paused: Boolean(project?.pausedAt),
pauseReason: project?.pauseReason ?? null,
windowStart: new Date(),
windowEnd: new Date(),
} satisfies BudgetPolicySummary;
}, [budgetOverview?.policies, project, resolvedCompanyId, routeProjectRef]);
const budgetMutation = useMutation({
mutationFn: (amount: number) =>
budgetsApi.upsertPolicy(resolvedCompanyId!, {
scopeType: "project",
scopeId: project?.id ?? routeProjectRef,
amount,
windowKind: "lifetime",
}),
onSuccess: () => {
if (!resolvedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
},
});
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}
@@ -469,6 +526,15 @@ export function ProjectDetail() {
/>
</Tabs>
{resolvedCompanyId ? (
<BudgetPolicyCard
summary={projectBudgetSummary}
compact
isSaving={budgetMutation.isPending}
onSave={(amount) => budgetMutation.mutate(amount)}
/>
) : null}
{activeTab === "overview" && (
<OverviewContent
project={project}