feat(costs): add task-level token attribution and task-cost rollups
This commit is contained in:
parent
c0cf678c4d
commit
73270c8d9f
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue