feat(costs): add task-level token attribution and task-cost rollups

This commit is contained in:
Nyk 2026-03-05 14:04:29 +07:00
parent c0cf678c4d
commit 73270c8d9f
6 changed files with 510 additions and 18 deletions

View File

@ -180,7 +180,7 @@ export async function GET(
* - connection_id: update direct_connections.last_heartbeat * - connection_id: update direct_connections.last_heartbeat
* - status: agent status override * - status: agent status override
* - last_activity: activity description * - last_activity: activity description
* - token_usage: { model, inputTokens, outputTokens } for inline token reporting * - token_usage: { model, inputTokens, outputTokens, taskId? } for inline token reporting
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
@ -221,10 +221,35 @@ export async function POST(
if (agent) { if (agent) {
const sessionId = `${agent.name}:cli`; const sessionId = `${agent.name}:cli`;
const parsedTaskId =
token_usage.taskId != null && Number.isFinite(Number(token_usage.taskId))
? Number(token_usage.taskId)
: null
let taskId: number | null = null
if (parsedTaskId && parsedTaskId > 0) {
const taskRow = db.prepare(
'SELECT id FROM tasks WHERE id = ? AND workspace_id = ?'
).get(parsedTaskId, workspaceId) as { id?: number } | undefined
if (taskRow?.id) {
taskId = taskRow.id
} else {
logger.warn({ taskId: parsedTaskId, workspaceId, agent: agent.name }, 'Ignoring token usage with unknown taskId')
}
}
db.prepare( db.prepare(
`INSERT INTO token_usage (model, session_id, input_tokens, output_tokens, created_at) `INSERT INTO token_usage (model, session_id, input_tokens, output_tokens, created_at, workspace_id, task_id)
VALUES (?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(token_usage.model, sessionId, token_usage.inputTokens, token_usage.outputTokens, now); ).run(
token_usage.model,
sessionId,
token_usage.inputTokens,
token_usage.outputTokens,
now,
workspaceId,
taskId
);
tokenRecorded = true; tokenRecorded = true;
} }
} }

View File

@ -8,6 +8,7 @@ import { logger } from '@/lib/logger'
import { getDatabase } from '@/lib/db' import { getDatabase } from '@/lib/db'
import { calculateTokenCost } from '@/lib/token-pricing' import { calculateTokenCost } from '@/lib/token-pricing'
import { getProviderSubscriptionFlags } from '@/lib/provider-subscriptions' import { getProviderSubscriptionFlags } from '@/lib/provider-subscriptions'
import { buildTaskCostReport, type TaskCostMetadata } from '@/lib/task-costs'
const DATA_PATH = config.tokensPath const DATA_PATH = config.tokensPath
@ -22,6 +23,8 @@ interface TokenUsageRecord {
totalTokens: number totalTokens: number
cost: number cost: number
operation: string operation: string
taskId?: number | null
workspaceId?: number
duration?: number duration?: number
} }
@ -40,6 +43,8 @@ interface ExportData {
sessions: Record<string, TokenStats> sessions: Record<string, TokenStats>
} }
interface TaskMetadataRow extends TaskCostMetadata {}
function extractAgentName(sessionId: string): string { function extractAgentName(sessionId: string): string {
const trimmed = sessionId.trim() const trimmed = sessionId.trim()
if (!trimmed) return 'unknown' if (!trimmed) return 'unknown'
@ -53,18 +58,21 @@ interface DbTokenUsageRow {
session_id: string session_id: string
input_tokens: number input_tokens: number
output_tokens: number output_tokens: number
task_id?: number | null
workspace_id?: number
created_at: number created_at: number
} }
function loadTokenDataFromDb(providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] { function loadTokenDataFromDb(workspaceId: number, providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
try { try {
const db = getDatabase() const db = getDatabase()
const rows = db.prepare(` const rows = db.prepare(`
SELECT id, model, session_id, input_tokens, output_tokens, created_at SELECT id, model, session_id, input_tokens, output_tokens, task_id, workspace_id, created_at
FROM token_usage FROM token_usage
WHERE workspace_id = ?
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
LIMIT 10000 LIMIT 10000
`).all() as DbTokenUsageRow[] `).all(workspaceId) as DbTokenUsageRow[]
return rows.map((row) => { return rows.map((row) => {
const totalTokens = row.input_tokens + row.output_tokens const totalTokens = row.input_tokens + row.output_tokens
@ -79,6 +87,8 @@ function loadTokenDataFromDb(providerSubscriptions: Record<string, boolean>): To
totalTokens, totalTokens,
cost: calculateTokenCost(row.model, row.input_tokens, row.output_tokens, { providerSubscriptions }), cost: calculateTokenCost(row.model, row.input_tokens, row.output_tokens, { providerSubscriptions }),
operation: 'heartbeat', operation: 'heartbeat',
taskId: row.task_id ?? null,
workspaceId: row.workspace_id ?? workspaceId,
} }
}) })
} catch (error) { } catch (error) {
@ -107,6 +117,8 @@ function normalizeTokenRecord(
totalTokens, totalTokens,
cost: Number(record.cost ?? calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })), cost: Number(record.cost ?? calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })),
operation: String(record.operation ?? 'chat_completion'), operation: String(record.operation ?? 'chat_completion'),
taskId: record.taskId != null && Number.isFinite(Number(record.taskId)) ? Number(record.taskId) : null,
workspaceId: record.workspaceId != null && Number.isFinite(Number(record.workspaceId)) ? Number(record.workspaceId) : 1,
duration: record.duration, duration: record.duration,
} }
} }
@ -124,6 +136,8 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] {
record.outputTokens, record.outputTokens,
record.totalTokens, record.totalTokens,
record.operation, record.operation,
record.taskId ?? '',
record.workspaceId ?? 1,
record.duration ?? '', record.duration ?? '',
].join('|') ].join('|')
if (seen.has(key)) continue if (seen.has(key)) continue
@ -134,7 +148,7 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] {
return deduped return deduped
} }
async function loadTokenDataFromFile(providerSubscriptions: Record<string, boolean>): Promise<TokenUsageRecord[]> { async function loadTokenDataFromFile(workspaceId: number, providerSubscriptions: Record<string, boolean>): Promise<TokenUsageRecord[]> {
try { try {
ensureDirExists(dirname(DATA_PATH)) ensureDirExists(dirname(DATA_PATH))
await access(DATA_PATH) await access(DATA_PATH)
@ -145,6 +159,11 @@ async function loadTokenDataFromFile(providerSubscriptions: Record<string, boole
return parsed return parsed
.map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record, providerSubscriptions)) .map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record, providerSubscriptions))
.filter((record): record is TokenUsageRecord => record !== null) .filter((record): record is TokenUsageRecord => record !== null)
.filter((record) => {
if (record.workspaceId === workspaceId) return true
// Backward compatibility for pre-workspace records
return workspaceId === 1 && (!record.workspaceId || record.workspaceId === 1)
})
} catch { } catch {
return [] return []
} }
@ -153,24 +172,24 @@ async function loadTokenDataFromFile(providerSubscriptions: Record<string, boole
/** /**
* Load token data from persistent file, falling back to deriving from session stores. * Load token data from persistent file, falling back to deriving from session stores.
*/ */
async function loadTokenData(): Promise<TokenUsageRecord[]> { async function loadTokenData(workspaceId: number): Promise<TokenUsageRecord[]> {
const providerSubscriptions = getProviderSubscriptionFlags() const providerSubscriptions = getProviderSubscriptionFlags()
const dbRecords = loadTokenDataFromDb(providerSubscriptions) const dbRecords = loadTokenDataFromDb(workspaceId, providerSubscriptions)
const fileRecords = await loadTokenDataFromFile(providerSubscriptions) const fileRecords = await loadTokenDataFromFile(workspaceId, providerSubscriptions)
const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp) const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp)
if (combined.length > 0) { if (combined.length > 0) {
return combined return combined
} }
// Final fallback: derive from in-memory sessions // Final fallback: derive from in-memory sessions
return deriveFromSessions(providerSubscriptions) return deriveFromSessions(workspaceId, providerSubscriptions)
} }
/** /**
* Derive token usage records from OpenClaw session stores. * Derive token usage records from OpenClaw session stores.
* Each session has totalTokens, inputTokens, outputTokens, model, etc. * Each session has totalTokens, inputTokens, outputTokens, model, etc.
*/ */
function deriveFromSessions(providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] { function deriveFromSessions(workspaceId: number, providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age
const records: TokenUsageRecord[] = [] const records: TokenUsageRecord[] = []
@ -192,6 +211,8 @@ function deriveFromSessions(providerSubscriptions: Record<string, boolean>): Tok
totalTokens, totalTokens,
cost, cost,
operation: session.chatType || 'chat', operation: session.chatType || 'chat',
taskId: null,
workspaceId,
}) })
} }
@ -253,17 +274,48 @@ function filterByTimeframe(records: TokenUsageRecord[], timeframe: string): Toke
return records.filter(record => record.timestamp >= cutoffTime) return records.filter(record => record.timestamp >= cutoffTime)
} }
function loadTaskMetadataById(workspaceId: number, taskIds: number[]): Record<number, TaskCostMetadata> {
if (taskIds.length === 0) return {}
const db = getDatabase()
const placeholders = taskIds.map(() => '?').join(', ')
const rows = db.prepare(`
SELECT
t.id,
t.title,
t.status,
t.priority,
t.assigned_to,
t.project_id,
p.name as project_name,
p.slug as project_slug,
p.ticket_prefix as project_prefix,
t.project_ticket_no
FROM tasks t
LEFT JOIN projects p
ON p.id = t.project_id AND p.workspace_id = t.workspace_id
WHERE t.workspace_id = ?
AND t.id IN (${placeholders})
`).all(workspaceId, ...taskIds) as TaskMetadataRow[]
const out: Record<number, TaskCostMetadata> = {}
for (const row of rows) {
out[row.id] = row
}
return out
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer') const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'list' const action = (searchParams.get('action') || 'list').trim().toLowerCase()
const timeframe = searchParams.get('timeframe') || 'all' const timeframe = searchParams.get('timeframe') || 'all'
const format = searchParams.get('format') || 'json' const format = searchParams.get('format') || 'json'
const tokenData = await loadTokenData() const workspaceId = auth.user.workspace_id ?? 1
const tokenData = await loadTokenData(workspaceId)
const filteredData = filterByTimeframe(tokenData, timeframe) const filteredData = filterByTimeframe(tokenData, timeframe)
if (action === 'list') { if (action === 'list') {
@ -377,6 +429,34 @@ export async function GET(request: NextRequest) {
}) })
} }
if (action === 'task-costs' || action === 'task_costs' || action === 'taskcosts') {
const attributedTaskIds = [...new Set(
filteredData
.map((record) => record.taskId)
.filter((taskId): taskId is number => Number.isFinite(taskId) && Number(taskId) > 0)
.map((taskId) => Number(taskId))
)]
const taskMetadataById = loadTaskMetadataById(workspaceId, attributedTaskIds)
const report = buildTaskCostReport(
filteredData.map((record) => ({
model: record.model,
agentName: record.agentName || extractAgentName(record.sessionId),
timestamp: record.timestamp,
totalTokens: record.totalTokens,
cost: record.cost,
taskId: record.taskId ?? null,
})),
taskMetadataById
)
return NextResponse.json({
...report,
timeframe,
recordCount: filteredData.length,
attributedRecordCount: filteredData.filter((record) => Number.isFinite(record.taskId)).length,
})
}
if (action === 'export') { if (action === 'export') {
const overallStats = calculateStats(filteredData) const overallStats = calculateStats(filteredData)
const modelStats: Record<string, TokenStats> = {} const modelStats: Record<string, TokenStats> = {}
@ -468,7 +548,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ trends, timeframe }) return NextResponse.json({ trends, timeframe })
} }
return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) return NextResponse.json({ error: 'Invalid action', action }, { status: 400 })
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Tokens API error') logger.error({ err: error }, 'Tokens API error')
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
@ -481,7 +561,8 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { model, sessionId, inputTokens, outputTokens, operation = 'chat_completion', duration } = body const workspaceId = auth.user.workspace_id ?? 1
const { model, sessionId, inputTokens, outputTokens, operation = 'chat_completion', duration, taskId } = body
if (!model || !sessionId || typeof inputTokens !== 'number' || typeof outputTokens !== 'number') { if (!model || !sessionId || typeof inputTokens !== 'number' || typeof outputTokens !== 'number') {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
@ -490,6 +571,19 @@ export async function POST(request: NextRequest) {
const totalTokens = inputTokens + outputTokens const totalTokens = inputTokens + outputTokens
const providerSubscriptions = getProviderSubscriptionFlags() const providerSubscriptions = getProviderSubscriptionFlags()
const cost = calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions }) const cost = calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })
const parsedTaskId =
taskId != null && Number.isFinite(Number(taskId)) && Number(taskId) > 0
? Number(taskId)
: null
let validatedTaskId: number | null = null
if (parsedTaskId) {
const db = getDatabase()
const taskRow = db.prepare(
'SELECT id FROM tasks WHERE id = ? AND workspace_id = ?'
).get(parsedTaskId, workspaceId) as { id?: number } | undefined
if (taskRow?.id) validatedTaskId = taskRow.id
}
const record: TokenUsageRecord = { const record: TokenUsageRecord = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
@ -502,11 +596,13 @@ export async function POST(request: NextRequest) {
totalTokens, totalTokens,
cost, cost,
operation, operation,
taskId: validatedTaskId,
workspaceId,
duration, duration,
} }
// Persist only manually posted usage records in the JSON file. // Persist only manually posted usage records in the JSON file.
const existingData = await loadTokenDataFromFile(providerSubscriptions) const existingData = await loadTokenDataFromFile(workspaceId, providerSubscriptions)
existingData.unshift(record) existingData.unshift(record)
if (existingData.length > 10000) { if (existingData.length > 10000) {

View File

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { buildTaskCostReport, calculateStats, type TaskCostMetadata, type TokenCostRecord } from '@/lib/task-costs'
describe('task-cost analytics', () => {
it('calculates stats correctly', () => {
const stats = calculateStats([
{ model: 'a', agentName: 'alpha', timestamp: 1000, totalTokens: 100, cost: 0.1 },
{ model: 'b', agentName: 'alpha', timestamp: 2000, totalTokens: 300, cost: 0.3 },
])
expect(stats.totalTokens).toBe(400)
expect(stats.totalCost).toBeCloseTo(0.4)
expect(stats.requestCount).toBe(2)
expect(stats.avgTokensPerRequest).toBe(200)
expect(stats.avgCostPerRequest).toBeCloseTo(0.2)
})
it('builds task, agent, project and unattributed rollups', () => {
const records: TokenCostRecord[] = [
{ model: 'sonnet', agentName: 'alpha', timestamp: Date.parse('2026-03-05T01:00:00Z'), totalTokens: 100, cost: 0.1, taskId: 101 },
{ model: 'sonnet', agentName: 'alpha', timestamp: Date.parse('2026-03-05T02:00:00Z'), totalTokens: 150, cost: 0.15, taskId: 101 },
{ model: 'haiku', agentName: 'beta', timestamp: Date.parse('2026-03-05T03:00:00Z'), totalTokens: 50, cost: 0.02, taskId: 202 },
{ model: 'haiku', agentName: 'beta', timestamp: Date.parse('2026-03-05T03:30:00Z'), totalTokens: 75, cost: 0.03 },
]
const taskMetadata: Record<number, TaskCostMetadata> = {
101: {
id: 101,
title: 'Task One',
status: 'in_progress',
priority: 'high',
assigned_to: 'alpha',
project_id: 1,
project_name: 'Core',
project_slug: 'core',
project_prefix: 'CORE',
project_ticket_no: 12,
},
202: {
id: 202,
title: 'Task Two',
status: 'assigned',
priority: 'medium',
assigned_to: 'beta',
project_id: 2,
project_name: 'Ops',
project_slug: 'ops',
project_prefix: 'OPS',
project_ticket_no: 7,
},
}
const report = buildTaskCostReport(records, taskMetadata)
expect(report.tasks).toHaveLength(2)
expect(report.tasks[0]?.taskId).toBe(101)
expect(report.tasks[0]?.stats.totalCost).toBeCloseTo(0.25)
expect(report.tasks[0]?.project.ticketRef).toBe('CORE-012')
expect(report.agents.alpha?.stats.totalCost).toBeCloseTo(0.25)
expect(report.agents.alpha?.taskIds).toEqual([101])
expect(report.agents.beta?.taskIds).toEqual([202])
expect(report.projects['1']?.taskCount).toBe(1)
expect(report.projects['2']?.taskCount).toBe(1)
expect(report.summary.totalCost).toBeCloseTo(0.27)
expect(report.unattributed.totalCost).toBeCloseTo(0.03)
})
})

View File

@ -753,6 +753,26 @@ const migrations: Migration[] = [
} }
} }
} }
},
{
id: '025_token_usage_task_attribution',
up: (db) => {
const hasTokenUsageTable = db
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'token_usage'`)
.get() as { ok?: number } | undefined
if (!hasTokenUsageTable?.ok) return
const cols = db.prepare(`PRAGMA table_info(token_usage)`).all() as Array<{ name: string }>
const hasCol = (name: string) => cols.some((c) => c.name === name)
if (!hasCol('task_id')) {
db.exec(`ALTER TABLE token_usage ADD COLUMN task_id INTEGER`)
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_task_id ON token_usage(task_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_task_time ON token_usage(workspace_id, task_id, created_at)`)
}
} }
] ]

221
src/lib/task-costs.ts Normal file
View File

@ -0,0 +1,221 @@
export interface TokenCostRecord {
model: string
agentName: string
timestamp: number
totalTokens: number
cost: number
taskId?: number | null
}
export interface TokenStats {
totalTokens: number
totalCost: number
requestCount: number
avgTokensPerRequest: number
avgCostPerRequest: number
}
export interface TaskCostMetadata {
id: number
title: string
status: string
priority: string
assigned_to?: string | null
project_id?: number | null
project_name?: string | null
project_slug?: string | null
project_ticket_no?: number | null
project_prefix?: string | null
}
export interface TaskCostEntry {
taskId: number
title: string
status: string
priority: string
assignedTo?: string | null
project: {
id?: number | null
name?: string | null
slug?: string | null
ticketRef?: string | null
}
stats: TokenStats
models: Record<string, TokenStats>
timeline: Array<{ date: string; cost: number; tokens: number }>
}
export interface AgentTaskCostEntry {
stats: TokenStats
taskCount: number
taskIds: number[]
}
export interface ProjectTaskCostEntry {
stats: TokenStats
taskCount: number
taskIds: number[]
}
export interface TaskCostReport {
summary: TokenStats
tasks: TaskCostEntry[]
agents: Record<string, AgentTaskCostEntry>
projects: Record<string, ProjectTaskCostEntry>
unattributed: TokenStats
}
export function calculateStats(records: TokenCostRecord[]): 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 groupByModel(records: TokenCostRecord[]): Record<string, TokenStats> {
const modelGroups: Record<string, TokenCostRecord[]> = {}
for (const record of records) {
if (!modelGroups[record.model]) modelGroups[record.model] = []
modelGroups[record.model].push(record)
}
const result: Record<string, TokenStats> = {}
for (const [model, modelRecords] of Object.entries(modelGroups)) {
result[model] = calculateStats(modelRecords)
}
return result
}
function buildTimeline(records: TokenCostRecord[]): Array<{ date: string; cost: number; tokens: number }> {
const byDate: Record<string, { cost: number; tokens: number }> = {}
for (const record of records) {
const date = new Date(record.timestamp).toISOString().split('T')[0]
if (!byDate[date]) {
byDate[date] = { cost: 0, tokens: 0 }
}
byDate[date].cost += record.cost
byDate[date].tokens += record.totalTokens
}
return Object.entries(byDate)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, totals]) => ({ date, ...totals }))
}
function formatTicketRef(prefix?: string | null, num?: number | null): string | null {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return null
return `${prefix}-${String(num).padStart(3, '0')}`
}
export function buildTaskCostReport(records: TokenCostRecord[], taskMetadata: Record<number, TaskCostMetadata>): TaskCostReport {
const attributedRecords = records.filter((record) => Number.isFinite(record.taskId))
const unattributedRecords = records.filter((record) => !Number.isFinite(record.taskId))
const byTask: Record<number, TokenCostRecord[]> = {}
for (const record of attributedRecords) {
const taskId = Number(record.taskId)
if (!taskMetadata[taskId]) continue
if (!byTask[taskId]) byTask[taskId] = []
byTask[taskId].push(record)
}
const tasks: TaskCostEntry[] = Object.entries(byTask)
.map(([taskIdRaw, taskRecords]) => {
const taskId = Number(taskIdRaw)
const meta = taskMetadata[taskId]
return {
taskId,
title: meta.title,
status: meta.status,
priority: meta.priority,
assignedTo: meta.assigned_to || null,
project: {
id: meta.project_id ?? null,
name: meta.project_name ?? null,
slug: meta.project_slug ?? null,
ticketRef: formatTicketRef(meta.project_prefix, meta.project_ticket_no),
},
stats: calculateStats(taskRecords),
models: groupByModel(taskRecords),
timeline: buildTimeline(taskRecords),
}
})
.sort((a, b) => b.stats.totalCost - a.stats.totalCost)
const byAgent: Record<string, TokenCostRecord[]> = {}
for (const record of attributedRecords) {
const taskId = Number(record.taskId)
if (!taskMetadata[taskId]) continue
if (!byAgent[record.agentName]) byAgent[record.agentName] = []
byAgent[record.agentName].push(record)
}
const agentTaskIds: Record<string, Set<number>> = {}
for (const task of tasks) {
const taskRecords = byTask[task.taskId] || []
for (const record of taskRecords) {
const agent = record.agentName
if (!agentTaskIds[agent]) agentTaskIds[agent] = new Set()
agentTaskIds[agent].add(task.taskId)
}
}
const agents: Record<string, AgentTaskCostEntry> = {}
for (const [agent, agentRecords] of Object.entries(byAgent)) {
const taskIds = [...(agentTaskIds[agent] || new Set<number>())].sort((a, b) => a - b)
agents[agent] = {
stats: calculateStats(agentRecords),
taskCount: taskIds.length,
taskIds,
}
}
const byProject: Record<string, TokenCostRecord[]> = {}
const projectTaskIds: Record<string, Set<number>> = {}
for (const record of attributedRecords) {
const taskId = Number(record.taskId)
const meta = taskMetadata[taskId]
if (!meta) continue
const key = meta.project_id ? String(meta.project_id) : 'unscoped'
if (!byProject[key]) byProject[key] = []
byProject[key].push(record)
if (!projectTaskIds[key]) projectTaskIds[key] = new Set()
projectTaskIds[key].add(taskId)
}
const projects: Record<string, ProjectTaskCostEntry> = {}
for (const [projectKey, projectRecords] of Object.entries(byProject)) {
const taskIds = [...(projectTaskIds[projectKey] || new Set<number>())].sort((a, b) => a - b)
projects[projectKey] = {
stats: calculateStats(projectRecords),
taskCount: taskIds.length,
taskIds,
}
}
return {
summary: calculateStats(attributedRecords.filter((record) => Number.isFinite(record.taskId) && taskMetadata[Number(record.taskId)])),
tasks,
agents,
projects,
unattributed: calculateStats(unattributedRecords),
}
}

View File

@ -97,4 +97,64 @@ test.describe('Agent Costs API', () => {
const res = await request.get('/api/tokens?action=agent-costs&timeframe=all') const res = await request.get('/api/tokens?action=agent-costs&timeframe=all')
expect(res.status()).toBe(401) expect(res.status()).toBe(401)
}) })
test('GET action=task-costs returns task-level attribution and unattributed rollup', async ({ request }) => {
const agentName = `e2e-taskcost-agent-${Date.now()}`
const createTaskRes = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: {
title: `E2E Task Cost ${Date.now()}`,
description: 'Task cost attribution test',
assigned_to: agentName,
},
})
expect(createTaskRes.status()).toBe(201)
const createdTask = await createTaskRes.json()
const taskId = createdTask.task.id as number
const postAttributed = await request.post('/api/tokens', {
headers: API_KEY_HEADER,
data: {
model: 'claude-sonnet-4',
sessionId: `${agentName}:chat`,
inputTokens: 300,
outputTokens: 100,
taskId,
},
})
expect(postAttributed.status()).toBe(200)
const postUnattributed = await request.post('/api/tokens', {
headers: API_KEY_HEADER,
data: {
model: 'claude-haiku-3.5',
sessionId: `${agentName}:chat`,
inputTokens: 50,
outputTokens: 50,
},
})
expect(postUnattributed.status()).toBe(200)
const res = await request.get('/api/tokens?action=task-costs&timeframe=hour', {
headers: API_KEY_HEADER,
})
const responseText = await res.text()
expect(res.status(), responseText).toBe(200)
const body = JSON.parse(responseText)
expect(body).toHaveProperty('summary')
expect(body).toHaveProperty('tasks')
expect(body).toHaveProperty('agents')
expect(body).toHaveProperty('projects')
expect(body).toHaveProperty('unattributed')
expect(Array.isArray(body.tasks)).toBe(true)
const matchingTask = body.tasks.find((task: any) => task.taskId === taskId)
expect(matchingTask).toBeTruthy()
expect(matchingTask.title).toBe(createdTask.task.title)
expect(matchingTask.stats.totalTokens).toBe(400)
expect(matchingTask.stats.requestCount).toBeGreaterThanOrEqual(1)
expect(body.agents[agentName].taskIds).toContain(taskId)
expect(body.unattributed.requestCount).toBeGreaterThanOrEqual(1)
})
}) })