mission-control/src/app/api/tokens/route.ts

437 lines
14 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { readFile, writeFile, access } from 'fs/promises'
import { dirname } from 'path'
import { config, ensureDirExists } from '@/lib/config'
import { requireRole } from '@/lib/auth'
import { getAllGatewaySessions } from '@/lib/sessions'
const DATA_PATH = config.tokensPath
interface TokenUsageRecord {
id: string
model: string
sessionId: string
timestamp: number
inputTokens: number
outputTokens: number
totalTokens: number
cost: number
operation: string
duration?: number
}
interface TokenStats {
totalTokens: number
totalCost: number
requestCount: number
avgTokensPerRequest: number
avgCostPerRequest: number
}
interface ExportData {
usage: TokenUsageRecord[]
summary: TokenStats
models: Record<string, TokenStats>
sessions: Record<string, TokenStats>
}
// Model pricing (cost per 1K tokens)
const MODEL_PRICING: Record<string, number> = {
'anthropic/claude-3-5-haiku-latest': 0.25,
'claude-3-5-haiku': 0.25,
'anthropic/claude-sonnet-4-20250514': 3.0,
'claude-sonnet-4': 3.0,
'anthropic/claude-opus-4-5': 15.0,
'claude-opus-4-5': 15.0,
'groq/llama-3.1-8b-instant': 0.05,
'groq/llama-3.3-70b-versatile': 0.59,
'moonshot/kimi-k2.5': 1.0,
'minimax/minimax-m2.1': 0.3,
'ollama/deepseek-r1:14b': 0.0,
'ollama/qwen2.5-coder:7b': 0.0,
'ollama/qwen2.5-coder:14b': 0.0,
}
function getModelCost(modelName: string): number {
if (MODEL_PRICING[modelName] !== undefined) return MODEL_PRICING[modelName]
for (const [model, cost] of Object.entries(MODEL_PRICING)) {
if (modelName.includes(model.split('/').pop() || '')) return cost
}
return 1.0
}
/**
* Load token data from persistent file, falling back to deriving from session stores.
*/
async function loadTokenData(): Promise<TokenUsageRecord[]> {
// First try loading from persistent token file
try {
ensureDirExists(dirname(DATA_PATH))
await access(DATA_PATH)
const data = await readFile(DATA_PATH, 'utf-8')
const records = JSON.parse(data)
if (Array.isArray(records) && records.length > 0) {
return records
}
} catch {
// File doesn't exist or is empty — derive from sessions
}
// Derive token usage from session stores
return deriveFromSessions()
}
/**
* Derive token usage records from OpenClaw session stores.
* Each session has totalTokens, inputTokens, outputTokens, model, etc.
*/
function deriveFromSessions(): TokenUsageRecord[] {
const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age
const records: TokenUsageRecord[] = []
for (const session of sessions) {
if (!session.totalTokens && !session.model) continue // Skip empty sessions
const totalTokens = session.totalTokens || 0
const inputTokens = session.inputTokens || Math.round(totalTokens * 0.7)
const outputTokens = session.outputTokens || totalTokens - inputTokens
const costPer1k = getModelCost(session.model || '')
const cost = (totalTokens / 1000) * costPer1k
records.push({
id: `session-${session.agent}-${session.key}`,
model: session.model || 'unknown',
sessionId: `${session.agent}:${session.chatType}`,
timestamp: session.updatedAt,
inputTokens,
outputTokens,
totalTokens,
cost,
operation: session.chatType || 'chat',
})
}
records.sort((a, b) => b.timestamp - a.timestamp)
return records
}
async function saveTokenData(data: TokenUsageRecord[]): Promise<void> {
ensureDirExists(dirname(DATA_PATH))
await writeFile(DATA_PATH, JSON.stringify(data, null, 2))
}
function calculateStats(records: TokenUsageRecord[]): TokenStats {
if (records.length === 0) {
return {
totalTokens: 0,
totalCost: 0,
requestCount: 0,
avgTokensPerRequest: 0,
avgCostPerRequest: 0,
}
}
const totalTokens = records.reduce((sum, r) => sum + r.totalTokens, 0)
const totalCost = records.reduce((sum, r) => sum + r.cost, 0)
const requestCount = records.length
return {
totalTokens,
totalCost,
requestCount,
avgTokensPerRequest: Math.round(totalTokens / requestCount),
avgCostPerRequest: totalCost / requestCount,
}
}
function filterByTimeframe(records: TokenUsageRecord[], timeframe: string): TokenUsageRecord[] {
const now = Date.now()
let cutoffTime: number
switch (timeframe) {
case 'hour':
cutoffTime = now - 60 * 60 * 1000
break
case 'day':
cutoffTime = now - 24 * 60 * 60 * 1000
break
case 'week':
cutoffTime = now - 7 * 24 * 60 * 60 * 1000
break
case 'month':
cutoffTime = now - 30 * 24 * 60 * 60 * 1000
break
case 'all':
default:
return records
}
return records.filter(record => record.timestamp >= cutoffTime)
}
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'list'
const timeframe = searchParams.get('timeframe') || 'all'
const format = searchParams.get('format') || 'json'
const tokenData = await loadTokenData()
const filteredData = filterByTimeframe(tokenData, timeframe)
if (action === 'list') {
return NextResponse.json({
usage: filteredData.slice(0, 100),
total: filteredData.length,
timeframe,
})
}
if (action === 'stats') {
const overallStats = calculateStats(filteredData)
const modelGroups = filteredData.reduce((acc, record) => {
if (!acc[record.model]) acc[record.model] = []
acc[record.model].push(record)
return acc
}, {} as Record<string, TokenUsageRecord[]>)
const modelStats: Record<string, TokenStats> = {}
for (const [model, records] of Object.entries(modelGroups)) {
modelStats[model] = calculateStats(records)
}
const sessionGroups = filteredData.reduce((acc, record) => {
if (!acc[record.sessionId]) acc[record.sessionId] = []
acc[record.sessionId].push(record)
return acc
}, {} as Record<string, TokenUsageRecord[]>)
const sessionStats: Record<string, TokenStats> = {}
for (const [sessionId, records] of Object.entries(sessionGroups)) {
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,
})
}
if (action === 'export') {
const overallStats = calculateStats(filteredData)
const modelStats: Record<string, TokenStats> = {}
const sessionStats: Record<string, TokenStats> = {}
const modelGroups = filteredData.reduce((acc, record) => {
if (!acc[record.model]) acc[record.model] = []
acc[record.model].push(record)
return acc
}, {} as Record<string, TokenUsageRecord[]>)
for (const [model, records] of Object.entries(modelGroups)) {
modelStats[model] = calculateStats(records)
}
const sessionGroups = filteredData.reduce((acc, record) => {
if (!acc[record.sessionId]) acc[record.sessionId] = []
acc[record.sessionId].push(record)
return acc
}, {} as Record<string, TokenUsageRecord[]>)
for (const [sessionId, records] of Object.entries(sessionGroups)) {
sessionStats[sessionId] = calculateStats(records)
}
const exportData: ExportData = {
usage: filteredData,
summary: overallStats,
models: modelStats,
sessions: sessionStats,
}
if (format === 'csv') {
const headers = ['timestamp', 'model', 'sessionId', 'operation', 'inputTokens', 'outputTokens', 'totalTokens', 'cost', 'duration']
const csvRows = [headers.join(',')]
filteredData.forEach(record => {
csvRows.push([
new Date(record.timestamp).toISOString(),
record.model,
record.sessionId,
record.operation,
record.inputTokens,
record.outputTokens,
record.totalTokens,
record.cost.toFixed(4),
record.duration || 0,
].join(','))
})
return new NextResponse(csvRows.join('\n'), {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename=token-usage-${timeframe}-${new Date().toISOString().split('T')[0]}.csv`,
},
})
}
return NextResponse.json(exportData, {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename=token-usage-${timeframe}-${new Date().toISOString().split('T')[0]}.json`,
},
})
}
if (action === 'trends') {
const now = Date.now()
const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000
const recentData = filteredData.filter(r => r.timestamp >= twentyFourHoursAgo)
const hourlyTrends: Record<string, { tokens: number; cost: number; requests: number }> = {}
recentData.forEach(record => {
const hour = new Date(record.timestamp).toISOString().slice(0, 13) + ':00:00.000Z'
if (!hourlyTrends[hour]) {
hourlyTrends[hour] = { tokens: 0, cost: 0, requests: 0 }
}
hourlyTrends[hour].tokens += record.totalTokens
hourlyTrends[hour].cost += record.cost
hourlyTrends[hour].requests += 1
})
const trends = Object.entries(hourlyTrends)
.sort(([a], [b]) => a.localeCompare(b))
.map(([timestamp, data]) => ({ timestamp, ...data }))
return NextResponse.json({ trends, timeframe })
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
} catch (error) {
console.error('Tokens API error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const body = await request.json()
const { model, sessionId, inputTokens, outputTokens, operation = 'chat_completion', duration } = body
if (!model || !sessionId || typeof inputTokens !== 'number' || typeof outputTokens !== 'number') {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
const totalTokens = inputTokens + outputTokens
const costPer1k = getModelCost(model)
const cost = (totalTokens / 1000) * costPer1k
const record: TokenUsageRecord = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
model,
sessionId,
timestamp: Date.now(),
inputTokens,
outputTokens,
totalTokens,
cost,
operation,
duration,
}
const existingData = await loadTokenData()
existingData.unshift(record)
if (existingData.length > 10000) {
existingData.splice(10000)
}
await saveTokenData(existingData)
return NextResponse.json({ success: true, record })
} catch (error) {
console.error('Error saving token usage:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}