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
This commit is contained in:
@@ -133,7 +133,9 @@ export type {
|
|||||||
CostSummary,
|
CostSummary,
|
||||||
CostByAgent,
|
CostByAgent,
|
||||||
CostByProviderModel,
|
CostByProviderModel,
|
||||||
|
CostByAgentModel,
|
||||||
CostWindowSpendRow,
|
CostWindowSpendRow,
|
||||||
|
CostByProject,
|
||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
HeartbeatRunEvent,
|
HeartbeatRunEvent,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ export interface CostByProviderModel {
|
|||||||
subscriptionOutputTokens: number;
|
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 */
|
/** spend per provider for a fixed rolling time window */
|
||||||
export interface CostWindowSpendRow {
|
export interface CostWindowSpendRow {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -58,3 +69,12 @@ export interface CostWindowSpendRow {
|
|||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export type {
|
|||||||
CompanySecret,
|
CompanySecret,
|
||||||
SecretProviderDescriptor,
|
SecretProviderDescriptor,
|
||||||
} from "./secrets.js";
|
} 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 {
|
export type {
|
||||||
HeartbeatRun,
|
HeartbeatRun,
|
||||||
HeartbeatRunEvent,
|
HeartbeatRunEvent,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { validate } from "../middleware/validate.js";
|
|||||||
import { costService, companyService, agentService, logActivity } from "../services/index.js";
|
import { costService, companyService, agentService, logActivity } from "../services/index.js";
|
||||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||||
|
import { badRequest } from "../errors.js";
|
||||||
|
|
||||||
export function costRoutes(db: Db) {
|
export function costRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -42,8 +43,12 @@ export function costRoutes(db: Db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function parseDateRange(query: Record<string, unknown>) {
|
function parseDateRange(query: Record<string, unknown>) {
|
||||||
const from = query.from ? new Date(query.from as string) : undefined;
|
const fromRaw = query.from as string | undefined;
|
||||||
const to = query.to ? new Date(query.to 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;
|
return (from || to) ? { from, to } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +68,14 @@ export function costRoutes(db: Db) {
|
|||||||
res.json(rows);
|
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) => {
|
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
|||||||
@@ -268,6 +268,32 @@ export function costService(db: Db) {
|
|||||||
return results.flat();
|
return results.flat();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
byAgentModel: async (companyId: string, range?: CostDateRange) => {
|
||||||
|
const conditions: ReturnType<typeof eq>[] = [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<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||||
|
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||||
|
outputTokens: sql<number>`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) => {
|
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||||
const issueIdAsText = sql<string>`${issues.id}::text`;
|
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||||
const runProjectLinks = db
|
const runProjectLinks = db
|
||||||
@@ -295,8 +321,8 @@ export function costService(db: Db) {
|
|||||||
.as("run_project_links");
|
.as("run_project_links");
|
||||||
|
|
||||||
const conditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
|
const conditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
|
||||||
if (range?.from) conditions.push(gte(heartbeatRuns.finishedAt, range.from));
|
if (range?.from) conditions.push(gte(heartbeatRuns.startedAt, range.from));
|
||||||
if (range?.to) conditions.push(lte(heartbeatRuns.finishedAt, range.to));
|
if (range?.to) conditions.push(lte(heartbeatRuns.startedAt, range.to));
|
||||||
|
|
||||||
const costCentsExpr = sql<number>`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`;
|
const costCentsExpr = sql<number>`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`;
|
||||||
|
|
||||||
|
|||||||
@@ -188,10 +188,11 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise
|
|||||||
const rateLimit = body.rate_limit;
|
const rateLimit = body.rate_limit;
|
||||||
if (rateLimit?.primary_window != null) {
|
if (rateLimit?.primary_window != null) {
|
||||||
const w = rateLimit.primary_window;
|
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 rawPct = w.used_percent ?? null;
|
||||||
const usedPercent = rawPct != 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;
|
: null;
|
||||||
windows.push({
|
windows.push({
|
||||||
label: secondsToWindowLabel(w.limit_window_seconds, "Primary"),
|
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) {
|
if (rateLimit?.secondary_window != null) {
|
||||||
const w = rateLimit.secondary_window;
|
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 rawPct = w.used_percent ?? null;
|
||||||
const usedPercent = rawPct != 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;
|
: null;
|
||||||
windows.push({
|
windows.push({
|
||||||
label: secondsToWindowLabel(w.limit_window_seconds, "Secondary"),
|
label: secondsToWindowLabel(w.limit_window_seconds, "Secondary"),
|
||||||
|
|||||||
@@ -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";
|
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 {
|
function dateParams(from?: string, to?: string): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (from) params.set("from", from);
|
if (from) params.set("from", from);
|
||||||
@@ -22,6 +14,8 @@ export const costsApi = {
|
|||||||
api.get<CostSummary>(`/companies/${companyId}/costs/summary${dateParams(from, to)}`),
|
api.get<CostSummary>(`/companies/${companyId}/costs/summary${dateParams(from, to)}`),
|
||||||
byAgent: (companyId: string, from?: string, to?: string) =>
|
byAgent: (companyId: string, from?: string, to?: string) =>
|
||||||
api.get<CostByAgent[]>(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
|
api.get<CostByAgent[]>(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
|
||||||
|
byAgentModel: (companyId: string, from?: string, to?: string) =>
|
||||||
|
api.get<CostByAgentModel[]>(`/companies/${companyId}/costs/by-agent-model${dateParams(from, to)}`),
|
||||||
byProject: (companyId: string, from?: string, to?: string) =>
|
byProject: (companyId: string, from?: string, to?: string) =>
|
||||||
api.get<CostByProject[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
|
api.get<CostByProject[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
|
||||||
byProvider: (companyId: string, from?: string, to?: string) =>
|
byProvider: (companyId: string, from?: string, to?: string) =>
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function ProviderQuotaCard({
|
|||||||
Subscription quota
|
Subscription quota
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{quotaWindows.map((qw, i) => {
|
{quotaWindows.map((qw) => {
|
||||||
const fillColor =
|
const fillColor =
|
||||||
qw.usedPercent == null
|
qw.usedPercent == null
|
||||||
? null
|
? null
|
||||||
@@ -184,7 +184,7 @@ export function ProviderQuotaCard({
|
|||||||
? "bg-yellow-400"
|
? "bg-yellow-400"
|
||||||
: "bg-green-400";
|
: "bg-green-400";
|
||||||
return (
|
return (
|
||||||
<div key={`qw-${i}`} className="space-y-1">
|
<div key={qw.label} className="space-y-1">
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
@@ -279,8 +279,8 @@ export function ProviderQuotaCard({
|
|||||||
/>
|
/>
|
||||||
{/* cost share overlay — narrower, opaque, shows relative cost weight */}
|
{/* cost share overlay — narrower, opaque, shows relative cost weight */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 left-0 bg-primary transition-[width] duration-150"
|
className="absolute inset-y-0 left-0 bg-primary/85 transition-[width] duration-150"
|
||||||
style={{ width: `${costPct}%`, opacity: 0.85 }}
|
style={{ width: `${costPct}%` }}
|
||||||
title={`${Math.round(costPct)}% of provider cost`}
|
title={`${Math.round(costPct)}% of provider cost`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { costsApi } from "../api/costs";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
@@ -15,7 +15,7 @@ import { StatusBadge } from "../components/StatusBadge";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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";
|
import { useDateRange, PRESET_LABELS, PRESET_KEYS } from "../hooks/useDateRange";
|
||||||
|
|
||||||
// sentinel used in query keys when no company is selected, to avoid polluting the cache
|
// 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({
|
const { data: spendData, isLoading: spendLoading, error: spendError } = useQuery({
|
||||||
queryKey: queryKeys.costs(companyId, from || undefined, to || undefined),
|
queryKey: queryKeys.costs(companyId, from || undefined, to || undefined),
|
||||||
queryFn: async () => {
|
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.summary(companyId, from || undefined, to || undefined),
|
||||||
costsApi.byAgent(companyId, from || undefined, to || undefined),
|
costsApi.byAgent(companyId, from || undefined, to || undefined),
|
||||||
costsApi.byProject(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,
|
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<Set<string>>(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<string, CostByAgentModel[]>();
|
||||||
|
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) ----------
|
// ---------- providers tab queries (polling — provider quota changes during agent runs) ----------
|
||||||
|
|
||||||
const { data: providerData } = useQuery({
|
const { data: providerData } = useQuery({
|
||||||
queryKey: queryKeys.usageByProvider(companyId, from || undefined, to || undefined),
|
queryKey: queryKeys.usageByProvider(companyId, from || undefined, to || undefined),
|
||||||
queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined),
|
queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined),
|
||||||
enabled: !!selectedCompanyId && customReady,
|
enabled: !!selectedCompanyId && customReady && mainTab === "providers",
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
@@ -120,7 +152,7 @@ export function Costs() {
|
|||||||
const { data: weekData } = useQuery({
|
const { data: weekData } = useQuery({
|
||||||
queryKey: queryKeys.usageByProvider(companyId, weekRange.from, weekRange.to),
|
queryKey: queryKeys.usageByProvider(companyId, weekRange.from, weekRange.to),
|
||||||
queryFn: () => costsApi.byProvider(companyId, weekRange.from, weekRange.to),
|
queryFn: () => costsApi.byProvider(companyId, weekRange.from, weekRange.to),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId && mainTab === "providers",
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
@@ -128,7 +160,9 @@ export function Costs() {
|
|||||||
const { data: windowData } = useQuery({
|
const { data: windowData } = useQuery({
|
||||||
queryKey: queryKeys.usageWindowSpend(companyId),
|
queryKey: queryKeys.usageWindowSpend(companyId),
|
||||||
queryFn: () => costsApi.windowSpend(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,
|
refetchInterval: 30_000,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
@@ -136,7 +170,7 @@ export function Costs() {
|
|||||||
const { data: quotaData } = useQuery({
|
const { data: quotaData } = useQuery({
|
||||||
queryKey: queryKeys.usageQuotaWindows(companyId),
|
queryKey: queryKeys.usageQuotaWindows(companyId),
|
||||||
queryFn: () => costsApi.quotaWindows(companyId),
|
queryFn: () => costsApi.quotaWindows(companyId),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId && mainTab === "providers",
|
||||||
// quota windows come from external provider apis; refresh every 5 minutes
|
// quota windows come from external provider apis; refresh every 5 minutes
|
||||||
refetchInterval: 300_000,
|
refetchInterval: 300_000,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
@@ -362,12 +396,24 @@ export function Costs() {
|
|||||||
<p className="text-sm text-muted-foreground">No cost events yet.</p>
|
<p className="text-sm text-muted-foreground">No cost events yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{spendData.byAgent.map((row) => (
|
{spendData.byAgent.map((row) => {
|
||||||
|
const modelRows = agentModelRows.get(row.agentId) ?? [];
|
||||||
|
const isExpanded = expandedAgents.has(row.agentId);
|
||||||
|
const hasBreakdown = modelRows.length > 0;
|
||||||
|
return (
|
||||||
|
<div key={row.agentId}>
|
||||||
<div
|
<div
|
||||||
key={row.agentId}
|
className={`flex items-start justify-between text-sm ${hasBreakdown ? "cursor-pointer select-none" : ""}`}
|
||||||
className="flex items-start justify-between text-sm"
|
onClick={() => hasBreakdown && toggleAgent(row.agentId)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{hasBreakdown ? (
|
||||||
|
isExpanded
|
||||||
|
? <ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||||
|
: <ChevronRight className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="w-3 h-3 shrink-0" />
|
||||||
|
)}
|
||||||
<Identity name={row.agentName ?? row.agentId} size="sm" />
|
<Identity name={row.agentName ?? row.agentId} size="sm" />
|
||||||
{row.agentStatus === "terminated" && (
|
{row.agentStatus === "terminated" && (
|
||||||
<StatusBadge status="terminated" />
|
<StatusBadge status="terminated" />
|
||||||
@@ -389,7 +435,40 @@ export function Costs() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{isExpanded && modelRows.length > 0 && (
|
||||||
|
<div className="ml-5 mt-1 mb-1 border-l border-border pl-3 space-y-1">
|
||||||
|
{modelRows.map((m) => {
|
||||||
|
const totalAgentCents = row.costCents;
|
||||||
|
const sharePct = totalAgentCents > 0
|
||||||
|
? Math.round((m.costCents / totalAgentCents) * 100)
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${m.provider}/${m.model}`}
|
||||||
|
className="flex items-start justify-between text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 truncate">
|
||||||
|
<span className="font-medium text-foreground">{providerDisplayName(m.provider)}</span>
|
||||||
|
<span className="mx-1 text-border">/</span>
|
||||||
|
<span className="font-mono">{m.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 ml-2">
|
||||||
|
<span className="font-medium text-foreground block">
|
||||||
|
{formatCents(m.costCents)}
|
||||||
|
<span className="font-normal text-muted-foreground ml-1">({sharePct}%)</span>
|
||||||
|
</span>
|
||||||
|
<span className="block">
|
||||||
|
in {formatTokens(m.inputTokens)} / out {formatTokens(m.outputTokens)} tok
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user