feat: add per-agent cost breakdown panel
- Add `agents` field to tokens action=stats response (groups by agent extracted from sessionId split on ':') - Add new action=agent-costs returning per-agent stats, model breakdown, session list, and daily cost/token timeline - New AgentCostPanel with summary cards, pie chart, trend lines, efficiency bars, and expandable ranking table - Add nav-rail entry in OBSERVE group after Tokens - Add ContentRouter case for agent-costs tab - Add 5 E2E tests for the new API endpoints
This commit is contained in:
parent
e77ec4cf2d
commit
98da58a8ba
|
|
@ -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<string, TokenUsageRecord[]>)
|
||||
|
||||
const agentStats: Record<string, TokenStats> = {}
|
||||
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<string, TokenUsageRecord[]>)
|
||||
|
||||
const agents: Record<string, {
|
||||
stats: TokenStats
|
||||
models: Record<string, TokenStats>
|
||||
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<string, TokenUsageRecord[]>)
|
||||
const models: Record<string, TokenStats> = {}
|
||||
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<string, { cost: number; tokens: number }>)
|
||||
|
||||
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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 <MemoryBrowserPanel />
|
||||
case 'tokens':
|
||||
return <TokenDashboardPanel />
|
||||
case 'agent-costs':
|
||||
return <AgentCostPanel />
|
||||
case 'users':
|
||||
return <UserManagementPanel />
|
||||
case 'history':
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const navGroups: NavGroup[] = [
|
|||
{ id: 'activity', label: 'Activity', icon: <ActivityIcon />, priority: true },
|
||||
{ id: 'logs', label: 'Logs', icon: <LogsIcon />, priority: false },
|
||||
{ id: 'tokens', label: 'Tokens', icon: <TokensIcon />, priority: false },
|
||||
{ id: 'agent-costs', label: 'Agent Costs', icon: <AgentCostsIcon />, priority: false },
|
||||
{ id: 'memory', label: 'Memory', icon: <MemoryIcon />, priority: false },
|
||||
],
|
||||
},
|
||||
|
|
@ -578,6 +579,17 @@ function IntegrationsIcon() {
|
|||
)
|
||||
}
|
||||
|
||||
function AgentCostsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="6" cy="5" r="3" />
|
||||
<path d="M1 14c0-2.8 2.2-5 5-5" />
|
||||
<circle cx="12" cy="10" r="3.5" />
|
||||
<path d="M12 8.5v3M10.8 10h2.4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
|
|
|||
|
|
@ -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<string, { totalTokens: number; totalCost: number; requestCount: number }>
|
||||
sessions: string[]
|
||||
timeline: Array<{ date: string; cost: number; tokens: number }>
|
||||
}
|
||||
|
||||
interface AgentCostsResponse {
|
||||
agents: Record<string, AgentCostData>
|
||||
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<AgentCostsResponse | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [expandedAgent, setExpandedAgent] = useState<string | null>(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<string>()
|
||||
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<string, string | number> = { 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 (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Agent Cost Breakdown</h1>
|
||||
<p className="text-muted-foreground mt-2">Per-agent token usage and spend analysis</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{(['hour', 'day', 'week', 'month'] as const).map((tf) => (
|
||||
<button
|
||||
key={tf}
|
||||
onClick={() => setSelectedTimeframe(tf)}
|
||||
className={`px-4 py-2 text-sm rounded-md font-medium transition-colors ${
|
||||
selectedTimeframe === tf
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/80'
|
||||
}`}
|
||||
>
|
||||
{tf.charAt(0).toUpperCase() + tf.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<span className="ml-3 text-muted-foreground">Loading agent costs...</span>
|
||||
</div>
|
||||
) : !data || agents.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
<div className="text-lg mb-2">No agent cost data available</div>
|
||||
<div className="text-sm">Cost data will appear once agents start using tokens</div>
|
||||
<button onClick={loadData} className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<div className="text-3xl font-bold text-foreground">{totalAgents}</div>
|
||||
<div className="text-sm text-muted-foreground">Total Agents</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<div className="text-3xl font-bold text-foreground">{formatCost(totalCost)}</div>
|
||||
<div className="text-sm text-muted-foreground">Total Cost ({selectedTimeframe})</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<div className="text-3xl font-bold text-orange-500">{mostExpensive?.[0] || '-'}</div>
|
||||
<div className="text-sm text-muted-foreground">Most Expensive Agent</div>
|
||||
{mostExpensive && <div className="text-xs text-muted-foreground mt-1">{formatCost(mostExpensive[1].stats.totalCost)}</div>}
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<div className="text-3xl font-bold text-green-500">{mostEfficient?.[0] || '-'}</div>
|
||||
<div className="text-sm text-muted-foreground">Most Efficient Agent</div>
|
||||
{mostEfficient && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
${(mostEfficient[1].stats.totalCost / Math.max(1, mostEfficient[1].stats.totalTokens) * 1000).toFixed(4)}/1K tokens
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
{/* Cost Distribution Pie */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Cost Distribution by Agent</h2>
|
||||
<div className="h-64">
|
||||
{pieData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No cost data</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" innerRadius={40} outerRadius={80} paddingAngle={5} dataKey="value">
|
||||
{pieData.map((_, i) => (
|
||||
<Cell key={`cell-${i}`} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => formatCost(Number(value))} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Trend Lines */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Cost Trends (Top 5 Agents)</h2>
|
||||
<div className="h-64">
|
||||
{trendData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No trend data</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => formatCost(Number(value))} />
|
||||
<Legend />
|
||||
{top5.map((name, i) => (
|
||||
<Line key={name} type="monotone" dataKey={name} stroke={COLORS[i % COLORS.length]} strokeWidth={2} dot={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Efficiency Comparison */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Cost Efficiency ($/1K Tokens per Agent)</h2>
|
||||
<div className="space-y-2">
|
||||
{efficiencyData.map(({ name, costPer1k }) => (
|
||||
<div key={name} className="flex items-center text-sm">
|
||||
<div className="w-32 truncate text-muted-foreground font-medium">{name}</div>
|
||||
<div className="flex-1 mx-3">
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${(costPer1k / maxCostPer1k) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 text-right text-xs text-muted-foreground">${costPer1k.toFixed(4)}/1K</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Cost Ranking Table */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Agent Cost Ranking</h2>
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{sortedAgents.map(([name, a], index) => (
|
||||
<div key={name} className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedAgent(expandedAgent === name ? null : name)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-secondary/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-6">#{index + 1}</span>
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-muted-foreground">
|
||||
{a.sessions.length} session{a.sessions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-foreground">{formatCost(a.stats.totalCost)}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatNumber(a.stats.totalTokens)} tokens</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-muted-foreground">{a.stats.requestCount} reqs</div>
|
||||
<div className="text-xs text-muted-foreground">{formatCost(a.stats.avgCostPerRequest)} avg</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-muted-foreground transition-transform ${expandedAgent === name ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
|
||||
>
|
||||
<polyline points="4,6 8,10 12,6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expandedAgent === name && (
|
||||
<div className="px-4 pb-4 border-t border-border bg-secondary/30">
|
||||
<div className="pt-3 text-sm">
|
||||
<h4 className="font-medium text-muted-foreground mb-2">Model Breakdown</h4>
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(a.models)
|
||||
.sort(([, x], [, y]) => y.totalCost - x.totalCost)
|
||||
.map(([model, stats]) => {
|
||||
const displayName = model.split('/').pop() || model
|
||||
return (
|
||||
<div key={model} className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{displayName}</span>
|
||||
<div className="flex gap-4">
|
||||
<span>{formatNumber(stats.totalTokens)} tokens</span>
|
||||
<span>{stats.requestCount} reqs</span>
|
||||
<span className="font-medium text-foreground">{formatCost(stats.totalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue