diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index 0b9f241..0a97a2f 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -215,10 +215,79 @@ export async function GET(request: NextRequest) { sessionStats[sessionId] = calculateStats(records) } + // Agent aggregation: extract agent name from sessionId (format: "agentName:chatType") + const agentGroups = filteredData.reduce((acc, record) => { + const agent = record.sessionId.split(':')[0] || 'unknown' + if (!acc[agent]) acc[agent] = [] + acc[agent].push(record) + return acc + }, {} as Record) + + const agentStats: Record = {} + for (const [agent, records] of Object.entries(agentGroups)) { + agentStats[agent] = calculateStats(records) + } + return NextResponse.json({ summary: overallStats, models: modelStats, sessions: sessionStats, + agents: agentStats, + timeframe, + recordCount: filteredData.length, + }) + } + + if (action === 'agent-costs') { + const agentGroups = filteredData.reduce((acc, record) => { + const agent = record.sessionId.split(':')[0] || 'unknown' + if (!acc[agent]) acc[agent] = [] + acc[agent].push(record) + return acc + }, {} as Record) + + const agents: Record + sessions: string[] + timeline: Array<{ date: string; cost: number; tokens: number }> + }> = {} + + for (const [agent, records] of Object.entries(agentGroups)) { + const stats = calculateStats(records) + + // Per-agent model breakdown + const modelGroups = records.reduce((acc, r) => { + if (!acc[r.model]) acc[r.model] = [] + acc[r.model].push(r) + return acc + }, {} as Record) + const models: Record = {} + for (const [model, mrs] of Object.entries(modelGroups)) { + models[model] = calculateStats(mrs) + } + + // Unique sessions + const sessions = [...new Set(records.map(r => r.sessionId))] + + // Daily timeline + const dailyMap = records.reduce((acc, r) => { + const date = new Date(r.timestamp).toISOString().split('T')[0] + if (!acc[date]) acc[date] = { cost: 0, tokens: 0 } + acc[date].cost += r.cost + acc[date].tokens += r.totalTokens + return acc + }, {} as Record) + + const timeline = Object.entries(dailyMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, data]) => ({ date, ...data })) + + agents[agent] = { stats, models, sessions, timeline } + } + + return NextResponse.json({ + agents, timeframe, recordCount: filteredData.length, }) diff --git a/src/app/page.tsx b/src/app/page.tsx index 36f5237..c751683 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,6 +10,7 @@ import { LogViewerPanel } from '@/components/panels/log-viewer-panel' import { CronManagementPanel } from '@/components/panels/cron-management-panel' import { MemoryBrowserPanel } from '@/components/panels/memory-browser-panel' import { TokenDashboardPanel } from '@/components/panels/token-dashboard-panel' +import { AgentCostPanel } from '@/components/panels/agent-cost-panel' import { SessionDetailsPanel } from '@/components/panels/session-details-panel' import { TaskBoardPanel } from '@/components/panels/task-board-panel' import { ActivityFeedPanel } from '@/components/panels/activity-feed-panel' @@ -163,6 +164,8 @@ function ContentRouter({ tab }: { tab: string }) { return case 'tokens': return + case 'agent-costs': + return case 'users': return case 'history': diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index 969d258..8166f56 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -33,6 +33,7 @@ const navGroups: NavGroup[] = [ { id: 'activity', label: 'Activity', icon: , priority: true }, { id: 'logs', label: 'Logs', icon: , priority: false }, { id: 'tokens', label: 'Tokens', icon: , priority: false }, + { id: 'agent-costs', label: 'Agent Costs', icon: , priority: false }, { id: 'memory', label: 'Memory', icon: , priority: false }, ], }, @@ -578,6 +579,17 @@ function IntegrationsIcon() { ) } +function AgentCostsIcon() { + return ( + + + + + + + ) +} + function SettingsIcon() { return ( diff --git a/src/components/panels/agent-cost-panel.tsx b/src/components/panels/agent-cost-panel.tsx new file mode 100644 index 0000000..fd34b75 --- /dev/null +++ b/src/components/panels/agent-cost-panel.tsx @@ -0,0 +1,302 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { + PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ResponsiveContainer, +} from 'recharts' + +interface AgentCostData { + stats: { totalTokens: number; totalCost: number; requestCount: number; avgTokensPerRequest: number; avgCostPerRequest: number } + models: Record + sessions: string[] + timeline: Array<{ date: string; cost: number; tokens: number }> +} + +interface AgentCostsResponse { + agents: Record + timeframe: string + recordCount: number +} + +const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d', '#ffc658', '#ff6b6b'] + +export function AgentCostPanel() { + const [selectedTimeframe, setSelectedTimeframe] = useState<'hour' | 'day' | 'week' | 'month'>('day') + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [expandedAgent, setExpandedAgent] = useState(null) + + const loadData = useCallback(async () => { + setIsLoading(true) + try { + const res = await fetch(`/api/tokens?action=agent-costs&timeframe=${selectedTimeframe}`) + const json = await res.json() + setData(json) + } catch (err) { + console.error('Failed to load agent costs:', err) + } finally { + setIsLoading(false) + } + }, [selectedTimeframe]) + + useEffect(() => { loadData() }, [loadData]) + + const formatNumber = (num: number) => { + if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M' + if (num >= 1_000) return (num / 1_000).toFixed(1) + 'K' + return num.toString() + } + + const formatCost = (cost: number) => '$' + cost.toFixed(4) + + const agents = data?.agents ? Object.entries(data.agents) : [] + const sortedAgents = agents.sort(([, a], [, b]) => b.stats.totalCost - a.stats.totalCost) + + const totalCost = agents.reduce((sum, [, a]) => sum + a.stats.totalCost, 0) + const totalAgents = agents.length + + const mostExpensive = sortedAgents[0] + const mostEfficient = agents.length > 0 + ? agents.reduce((best, curr) => { + const currCostPer1k = curr[1].stats.totalCost / Math.max(1, curr[1].stats.totalTokens) * 1000 + const bestCostPer1k = best[1].stats.totalCost / Math.max(1, best[1].stats.totalTokens) * 1000 + return currCostPer1k < bestCostPer1k ? curr : best + }) + : null + + // Pie chart data + const pieData = sortedAgents.slice(0, 8).map(([name, a]) => ({ + name, + value: a.stats.totalCost, + })) + + // Line chart: top 5 agents over time + const top5 = sortedAgents.slice(0, 5).map(([name]) => name) + const allDates = new Set() + for (const [name, a] of agents) { + if (top5.includes(name)) { + for (const t of a.timeline) allDates.add(t.date) + } + } + const trendData = [...allDates].sort().map(date => { + const point: Record = { date: date.slice(5) } // MM-DD + for (const name of top5) { + const entry = data?.agents[name]?.timeline.find(t => t.date === date) + point[name] = entry?.cost ?? 0 + } + return point + }) + + // Efficiency bars + const efficiencyData = sortedAgents.map(([name, a]) => ({ + name, + costPer1k: a.stats.totalCost / Math.max(1, a.stats.totalTokens) * 1000, + })) + const maxCostPer1k = Math.max(...efficiencyData.map(d => d.costPer1k), 0.0001) + + return ( +
+ {/* Header */} +
+
+
+

Agent Cost Breakdown

+

Per-agent token usage and spend analysis

+
+
+ {(['hour', 'day', 'week', 'month'] as const).map((tf) => ( + + ))} +
+
+
+ + {isLoading ? ( +
+
+ Loading agent costs... +
+ ) : !data || agents.length === 0 ? ( +
+
No agent cost data available
+
Cost data will appear once agents start using tokens
+ +
+ ) : ( +
+ {/* Summary Cards */} +
+
+
{totalAgents}
+
Total Agents
+
+
+
{formatCost(totalCost)}
+
Total Cost ({selectedTimeframe})
+
+
+
{mostExpensive?.[0] || '-'}
+
Most Expensive Agent
+ {mostExpensive &&
{formatCost(mostExpensive[1].stats.totalCost)}
} +
+
+
{mostEfficient?.[0] || '-'}
+
Most Efficient Agent
+ {mostEfficient && ( +
+ ${(mostEfficient[1].stats.totalCost / Math.max(1, mostEfficient[1].stats.totalTokens) * 1000).toFixed(4)}/1K tokens +
+ )} +
+
+ + {/* Charts */} +
+ {/* Cost Distribution Pie */} +
+

Cost Distribution by Agent

+
+ {pieData.length === 0 ? ( +
No cost data
+ ) : ( + + + + {pieData.map((_, i) => ( + + ))} + + formatCost(Number(value))} /> + + + + )} +
+
+ + {/* Cost Trend Lines */} +
+

Cost Trends (Top 5 Agents)

+
+ {trendData.length === 0 ? ( +
No trend data
+ ) : ( + + + + + + formatCost(Number(value))} /> + + {top5.map((name, i) => ( + + ))} + + + )} +
+
+
+ + {/* Cost Efficiency Comparison */} +
+

Cost Efficiency ($/1K Tokens per Agent)

+
+ {efficiencyData.map(({ name, costPer1k }) => ( +
+
{name}
+
+
+
+
+
+
${costPer1k.toFixed(4)}/1K
+
+ ))} +
+
+ + {/* Agent Cost Ranking Table */} +
+

Agent Cost Ranking

+
+ {sortedAgents.map(([name, a], index) => ( +
+ + + {expandedAgent === name && ( +
+
+

Model Breakdown

+
+ {Object.entries(a.models) + .sort(([, x], [, y]) => y.totalCost - x.totalCost) + .map(([model, stats]) => { + const displayName = model.split('/').pop() || model + return ( +
+ {displayName} +
+ {formatNumber(stats.totalTokens)} tokens + {stats.requestCount} reqs + {formatCost(stats.totalCost)} +
+
+ ) + })} +
+
+
+ )} +
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/tests/agent-costs.spec.ts b/tests/agent-costs.spec.ts new file mode 100644 index 0000000..1eb8d09 --- /dev/null +++ b/tests/agent-costs.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER } from './helpers' + +test.describe('Agent Costs API', () => { + test('GET action=stats includes agents field', async ({ request }) => { + const res = await request.get('/api/tokens?action=stats&timeframe=all', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body).toHaveProperty('summary') + expect(body).toHaveProperty('models') + expect(body).toHaveProperty('sessions') + expect(body).toHaveProperty('agents') + expect(body).toHaveProperty('timeframe') + expect(body).toHaveProperty('recordCount') + expect(typeof body.agents).toBe('object') + }) + + test('GET action=agent-costs returns per-agent breakdown', async ({ request }) => { + const res = await request.get('/api/tokens?action=agent-costs&timeframe=all', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body).toHaveProperty('agents') + expect(body).toHaveProperty('timeframe') + expect(body).toHaveProperty('recordCount') + expect(typeof body.agents).toBe('object') + + // If there are agents, verify structure + for (const [, agentData] of Object.entries(body.agents) as [string, any][]) { + expect(agentData).toHaveProperty('stats') + expect(agentData).toHaveProperty('models') + expect(agentData).toHaveProperty('sessions') + expect(agentData).toHaveProperty('timeline') + expect(agentData.stats).toHaveProperty('totalTokens') + expect(agentData.stats).toHaveProperty('totalCost') + expect(agentData.stats).toHaveProperty('requestCount') + expect(Array.isArray(agentData.sessions)).toBe(true) + expect(Array.isArray(agentData.timeline)).toBe(true) + } + }) + + test('GET action=agent-costs respects timeframe filtering', async ({ request }) => { + // Create a token record to ensure data exists + await request.post('/api/tokens', { + headers: API_KEY_HEADER, + data: { + model: 'claude-sonnet-4', + sessionId: 'e2e-cost-agent:chat', + inputTokens: 100, + outputTokens: 50, + }, + }) + + const resHour = await request.get('/api/tokens?action=agent-costs&timeframe=hour', { + headers: API_KEY_HEADER, + }) + expect(resHour.status()).toBe(200) + const hourData = await resHour.json() + expect(hourData.timeframe).toBe('hour') + + const resMonth = await request.get('/api/tokens?action=agent-costs&timeframe=month', { + headers: API_KEY_HEADER, + }) + expect(resMonth.status()).toBe(200) + const monthData = await resMonth.json() + expect(monthData.timeframe).toBe('month') + // Month should include at least as many records as hour + expect(monthData.recordCount).toBeGreaterThanOrEqual(hourData.recordCount) + }) + + test('POST /api/tokens records data that appears in agent-costs', async ({ request }) => { + const agentName = `e2e-costtest-${Date.now()}` + const postRes = await request.post('/api/tokens', { + headers: API_KEY_HEADER, + data: { + model: 'claude-sonnet-4', + sessionId: `${agentName}:chat`, + inputTokens: 500, + outputTokens: 200, + }, + }) + expect(postRes.status()).toBe(200) + + const res = await request.get('/api/tokens?action=agent-costs&timeframe=hour', { + headers: API_KEY_HEADER, + }) + const body = await res.json() + expect(body.agents).toHaveProperty(agentName) + expect(body.agents[agentName].stats.totalTokens).toBe(700) + expect(body.agents[agentName].stats.requestCount).toBe(1) + }) + + test('GET action=agent-costs requires auth', async ({ request }) => { + const res = await request.get('/api/tokens?action=agent-costs&timeframe=all') + expect(res.status()).toBe(401) + }) +})