Spaces:
Runtime error
Runtime error
chore: merge main into feat/task-outcome-feedback-192 to resolve PR #207 conflicts
Browse files- src/app/api/agents/[id]/heartbeat/route.ts +29 -4
- src/app/api/tokens/route.ts +110 -14
- src/lib/__tests__/task-costs.test.ts +70 -0
- src/lib/migrations.ts +21 -1
- src/lib/task-costs.ts +221 -0
- tests/agent-costs.spec.ts +60 -0
src/app/api/agents/[id]/heartbeat/route.ts
CHANGED
|
@@ -180,7 +180,7 @@ export async function GET(
|
|
| 180 |
* - connection_id: update direct_connections.last_heartbeat
|
| 181 |
* - status: agent status override
|
| 182 |
* - last_activity: activity description
|
| 183 |
-
* - token_usage: { model, inputTokens, outputTokens } for inline token reporting
|
| 184 |
*/
|
| 185 |
export async function POST(
|
| 186 |
request: NextRequest,
|
|
@@ -221,10 +221,35 @@ export async function POST(
|
|
| 221 |
|
| 222 |
if (agent) {
|
| 223 |
const sessionId = `${agent.name}:cli`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
db.prepare(
|
| 225 |
-
`INSERT INTO token_usage (model, session_id, input_tokens, output_tokens, created_at)
|
| 226 |
-
VALUES (?, ?, ?, ?, ?)`
|
| 227 |
-
).run(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
tokenRecorded = true;
|
| 229 |
}
|
| 230 |
}
|
|
|
|
| 180 |
* - connection_id: update direct_connections.last_heartbeat
|
| 181 |
* - status: agent status override
|
| 182 |
* - last_activity: activity description
|
| 183 |
+
* - token_usage: { model, inputTokens, outputTokens, taskId? } for inline token reporting
|
| 184 |
*/
|
| 185 |
export async function POST(
|
| 186 |
request: NextRequest,
|
|
|
|
| 221 |
|
| 222 |
if (agent) {
|
| 223 |
const sessionId = `${agent.name}:cli`;
|
| 224 |
+
const parsedTaskId =
|
| 225 |
+
token_usage.taskId != null && Number.isFinite(Number(token_usage.taskId))
|
| 226 |
+
? Number(token_usage.taskId)
|
| 227 |
+
: null
|
| 228 |
+
|
| 229 |
+
let taskId: number | null = null
|
| 230 |
+
if (parsedTaskId && parsedTaskId > 0) {
|
| 231 |
+
const taskRow = db.prepare(
|
| 232 |
+
'SELECT id FROM tasks WHERE id = ? AND workspace_id = ?'
|
| 233 |
+
).get(parsedTaskId, workspaceId) as { id?: number } | undefined
|
| 234 |
+
if (taskRow?.id) {
|
| 235 |
+
taskId = taskRow.id
|
| 236 |
+
} else {
|
| 237 |
+
logger.warn({ taskId: parsedTaskId, workspaceId, agent: agent.name }, 'Ignoring token usage with unknown taskId')
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
db.prepare(
|
| 242 |
+
`INSERT INTO token_usage (model, session_id, input_tokens, output_tokens, created_at, workspace_id, task_id)
|
| 243 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
| 244 |
+
).run(
|
| 245 |
+
token_usage.model,
|
| 246 |
+
sessionId,
|
| 247 |
+
token_usage.inputTokens,
|
| 248 |
+
token_usage.outputTokens,
|
| 249 |
+
now,
|
| 250 |
+
workspaceId,
|
| 251 |
+
taskId
|
| 252 |
+
);
|
| 253 |
tokenRecorded = true;
|
| 254 |
}
|
| 255 |
}
|
src/app/api/tokens/route.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { logger } from '@/lib/logger'
|
|
| 8 |
import { getDatabase } from '@/lib/db'
|
| 9 |
import { calculateTokenCost } from '@/lib/token-pricing'
|
| 10 |
import { getProviderSubscriptionFlags } from '@/lib/provider-subscriptions'
|
|
|
|
| 11 |
|
| 12 |
const DATA_PATH = config.tokensPath
|
| 13 |
|
|
@@ -22,6 +23,8 @@ interface TokenUsageRecord {
|
|
| 22 |
totalTokens: number
|
| 23 |
cost: number
|
| 24 |
operation: string
|
|
|
|
|
|
|
| 25 |
duration?: number
|
| 26 |
}
|
| 27 |
|
|
@@ -40,6 +43,8 @@ interface ExportData {
|
|
| 40 |
sessions: Record<string, TokenStats>
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
| 43 |
function extractAgentName(sessionId: string): string {
|
| 44 |
const trimmed = sessionId.trim()
|
| 45 |
if (!trimmed) return 'unknown'
|
|
@@ -53,18 +58,21 @@ interface DbTokenUsageRow {
|
|
| 53 |
session_id: string
|
| 54 |
input_tokens: number
|
| 55 |
output_tokens: number
|
|
|
|
|
|
|
| 56 |
created_at: number
|
| 57 |
}
|
| 58 |
|
| 59 |
-
function loadTokenDataFromDb(providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
|
| 60 |
try {
|
| 61 |
const db = getDatabase()
|
| 62 |
const rows = db.prepare(`
|
| 63 |
-
SELECT id, model, session_id, input_tokens, output_tokens, created_at
|
| 64 |
FROM token_usage
|
|
|
|
| 65 |
ORDER BY created_at DESC, id DESC
|
| 66 |
LIMIT 10000
|
| 67 |
-
`).all() as DbTokenUsageRow[]
|
| 68 |
|
| 69 |
return rows.map((row) => {
|
| 70 |
const totalTokens = row.input_tokens + row.output_tokens
|
|
@@ -79,6 +87,8 @@ function loadTokenDataFromDb(providerSubscriptions: Record<string, boolean>): To
|
|
| 79 |
totalTokens,
|
| 80 |
cost: calculateTokenCost(row.model, row.input_tokens, row.output_tokens, { providerSubscriptions }),
|
| 81 |
operation: 'heartbeat',
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
})
|
| 84 |
} catch (error) {
|
|
@@ -107,6 +117,8 @@ function normalizeTokenRecord(
|
|
| 107 |
totalTokens,
|
| 108 |
cost: Number(record.cost ?? calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })),
|
| 109 |
operation: String(record.operation ?? 'chat_completion'),
|
|
|
|
|
|
|
| 110 |
duration: record.duration,
|
| 111 |
}
|
| 112 |
}
|
|
@@ -124,6 +136,8 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] {
|
|
| 124 |
record.outputTokens,
|
| 125 |
record.totalTokens,
|
| 126 |
record.operation,
|
|
|
|
|
|
|
| 127 |
record.duration ?? '',
|
| 128 |
].join('|')
|
| 129 |
if (seen.has(key)) continue
|
|
@@ -134,7 +148,7 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] {
|
|
| 134 |
return deduped
|
| 135 |
}
|
| 136 |
|
| 137 |
-
async function loadTokenDataFromFile(providerSubscriptions: Record<string, boolean>): Promise<TokenUsageRecord[]> {
|
| 138 |
try {
|
| 139 |
ensureDirExists(dirname(DATA_PATH))
|
| 140 |
await access(DATA_PATH)
|
|
@@ -145,6 +159,11 @@ async function loadTokenDataFromFile(providerSubscriptions: Record<string, boole
|
|
| 145 |
return parsed
|
| 146 |
.map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record, providerSubscriptions))
|
| 147 |
.filter((record): record is TokenUsageRecord => record !== null)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
} catch {
|
| 149 |
return []
|
| 150 |
}
|
|
@@ -153,24 +172,24 @@ async function loadTokenDataFromFile(providerSubscriptions: Record<string, boole
|
|
| 153 |
/**
|
| 154 |
* Load token data from persistent file, falling back to deriving from session stores.
|
| 155 |
*/
|
| 156 |
-
async function loadTokenData(): Promise<TokenUsageRecord[]> {
|
| 157 |
const providerSubscriptions = getProviderSubscriptionFlags()
|
| 158 |
-
const dbRecords = loadTokenDataFromDb(providerSubscriptions)
|
| 159 |
-
const fileRecords = await loadTokenDataFromFile(providerSubscriptions)
|
| 160 |
const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp)
|
| 161 |
if (combined.length > 0) {
|
| 162 |
return combined
|
| 163 |
}
|
| 164 |
|
| 165 |
// Final fallback: derive from in-memory sessions
|
| 166 |
-
return deriveFromSessions(providerSubscriptions)
|
| 167 |
}
|
| 168 |
|
| 169 |
/**
|
| 170 |
* Derive token usage records from OpenClaw session stores.
|
| 171 |
* Each session has totalTokens, inputTokens, outputTokens, model, etc.
|
| 172 |
*/
|
| 173 |
-
function deriveFromSessions(providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
|
| 174 |
const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age
|
| 175 |
const records: TokenUsageRecord[] = []
|
| 176 |
|
|
@@ -192,6 +211,8 @@ function deriveFromSessions(providerSubscriptions: Record<string, boolean>): Tok
|
|
| 192 |
totalTokens,
|
| 193 |
cost,
|
| 194 |
operation: session.chatType || 'chat',
|
|
|
|
|
|
|
| 195 |
})
|
| 196 |
}
|
| 197 |
|
|
@@ -253,17 +274,48 @@ function filterByTimeframe(records: TokenUsageRecord[], timeframe: string): Toke
|
|
| 253 |
return records.filter(record => record.timestamp >= cutoffTime)
|
| 254 |
}
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
export async function GET(request: NextRequest) {
|
| 257 |
const auth = requireRole(request, 'viewer')
|
| 258 |
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 259 |
|
| 260 |
try {
|
| 261 |
const { searchParams } = new URL(request.url)
|
| 262 |
-
const action = searchParams.get('action') || 'list'
|
| 263 |
const timeframe = searchParams.get('timeframe') || 'all'
|
| 264 |
const format = searchParams.get('format') || 'json'
|
| 265 |
|
| 266 |
-
const
|
|
|
|
| 267 |
const filteredData = filterByTimeframe(tokenData, timeframe)
|
| 268 |
|
| 269 |
if (action === 'list') {
|
|
@@ -377,6 +429,34 @@ export async function GET(request: NextRequest) {
|
|
| 377 |
})
|
| 378 |
}
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
if (action === 'export') {
|
| 381 |
const overallStats = calculateStats(filteredData)
|
| 382 |
const modelStats: Record<string, TokenStats> = {}
|
|
@@ -468,7 +548,7 @@ export async function GET(request: NextRequest) {
|
|
| 468 |
return NextResponse.json({ trends, timeframe })
|
| 469 |
}
|
| 470 |
|
| 471 |
-
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
| 472 |
} catch (error) {
|
| 473 |
logger.error({ err: error }, 'Tokens API error')
|
| 474 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
@@ -481,7 +561,8 @@ export async function POST(request: NextRequest) {
|
|
| 481 |
|
| 482 |
try {
|
| 483 |
const body = await request.json()
|
| 484 |
-
const
|
|
|
|
| 485 |
|
| 486 |
if (!model || !sessionId || typeof inputTokens !== 'number' || typeof outputTokens !== 'number') {
|
| 487 |
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
|
@@ -490,6 +571,19 @@ export async function POST(request: NextRequest) {
|
|
| 490 |
const totalTokens = inputTokens + outputTokens
|
| 491 |
const providerSubscriptions = getProviderSubscriptionFlags()
|
| 492 |
const cost = calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
|
| 494 |
const record: TokenUsageRecord = {
|
| 495 |
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
@@ -502,11 +596,13 @@ export async function POST(request: NextRequest) {
|
|
| 502 |
totalTokens,
|
| 503 |
cost,
|
| 504 |
operation,
|
|
|
|
|
|
|
| 505 |
duration,
|
| 506 |
}
|
| 507 |
|
| 508 |
// Persist only manually posted usage records in the JSON file.
|
| 509 |
-
const existingData = await loadTokenDataFromFile(providerSubscriptions)
|
| 510 |
existingData.unshift(record)
|
| 511 |
|
| 512 |
if (existingData.length > 10000) {
|
|
|
|
| 8 |
import { getDatabase } from '@/lib/db'
|
| 9 |
import { calculateTokenCost } from '@/lib/token-pricing'
|
| 10 |
import { getProviderSubscriptionFlags } from '@/lib/provider-subscriptions'
|
| 11 |
+
import { buildTaskCostReport, type TaskCostMetadata } from '@/lib/task-costs'
|
| 12 |
|
| 13 |
const DATA_PATH = config.tokensPath
|
| 14 |
|
|
|
|
| 23 |
totalTokens: number
|
| 24 |
cost: number
|
| 25 |
operation: string
|
| 26 |
+
taskId?: number | null
|
| 27 |
+
workspaceId?: number
|
| 28 |
duration?: number
|
| 29 |
}
|
| 30 |
|
|
|
|
| 43 |
sessions: Record<string, TokenStats>
|
| 44 |
}
|
| 45 |
|
| 46 |
+
interface TaskMetadataRow extends TaskCostMetadata {}
|
| 47 |
+
|
| 48 |
function extractAgentName(sessionId: string): string {
|
| 49 |
const trimmed = sessionId.trim()
|
| 50 |
if (!trimmed) return 'unknown'
|
|
|
|
| 58 |
session_id: string
|
| 59 |
input_tokens: number
|
| 60 |
output_tokens: number
|
| 61 |
+
task_id?: number | null
|
| 62 |
+
workspace_id?: number
|
| 63 |
created_at: number
|
| 64 |
}
|
| 65 |
|
| 66 |
+
function loadTokenDataFromDb(workspaceId: number, providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
|
| 67 |
try {
|
| 68 |
const db = getDatabase()
|
| 69 |
const rows = db.prepare(`
|
| 70 |
+
SELECT id, model, session_id, input_tokens, output_tokens, task_id, workspace_id, created_at
|
| 71 |
FROM token_usage
|
| 72 |
+
WHERE workspace_id = ?
|
| 73 |
ORDER BY created_at DESC, id DESC
|
| 74 |
LIMIT 10000
|
| 75 |
+
`).all(workspaceId) as DbTokenUsageRow[]
|
| 76 |
|
| 77 |
return rows.map((row) => {
|
| 78 |
const totalTokens = row.input_tokens + row.output_tokens
|
|
|
|
| 87 |
totalTokens,
|
| 88 |
cost: calculateTokenCost(row.model, row.input_tokens, row.output_tokens, { providerSubscriptions }),
|
| 89 |
operation: 'heartbeat',
|
| 90 |
+
taskId: row.task_id ?? null,
|
| 91 |
+
workspaceId: row.workspace_id ?? workspaceId,
|
| 92 |
}
|
| 93 |
})
|
| 94 |
} catch (error) {
|
|
|
|
| 117 |
totalTokens,
|
| 118 |
cost: Number(record.cost ?? calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })),
|
| 119 |
operation: String(record.operation ?? 'chat_completion'),
|
| 120 |
+
taskId: record.taskId != null && Number.isFinite(Number(record.taskId)) ? Number(record.taskId) : null,
|
| 121 |
+
workspaceId: record.workspaceId != null && Number.isFinite(Number(record.workspaceId)) ? Number(record.workspaceId) : 1,
|
| 122 |
duration: record.duration,
|
| 123 |
}
|
| 124 |
}
|
|
|
|
| 136 |
record.outputTokens,
|
| 137 |
record.totalTokens,
|
| 138 |
record.operation,
|
| 139 |
+
record.taskId ?? '',
|
| 140 |
+
record.workspaceId ?? 1,
|
| 141 |
record.duration ?? '',
|
| 142 |
].join('|')
|
| 143 |
if (seen.has(key)) continue
|
|
|
|
| 148 |
return deduped
|
| 149 |
}
|
| 150 |
|
| 151 |
+
async function loadTokenDataFromFile(workspaceId: number, providerSubscriptions: Record<string, boolean>): Promise<TokenUsageRecord[]> {
|
| 152 |
try {
|
| 153 |
ensureDirExists(dirname(DATA_PATH))
|
| 154 |
await access(DATA_PATH)
|
|
|
|
| 159 |
return parsed
|
| 160 |
.map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record, providerSubscriptions))
|
| 161 |
.filter((record): record is TokenUsageRecord => record !== null)
|
| 162 |
+
.filter((record) => {
|
| 163 |
+
if (record.workspaceId === workspaceId) return true
|
| 164 |
+
// Backward compatibility for pre-workspace records
|
| 165 |
+
return workspaceId === 1 && (!record.workspaceId || record.workspaceId === 1)
|
| 166 |
+
})
|
| 167 |
} catch {
|
| 168 |
return []
|
| 169 |
}
|
|
|
|
| 172 |
/**
|
| 173 |
* Load token data from persistent file, falling back to deriving from session stores.
|
| 174 |
*/
|
| 175 |
+
async function loadTokenData(workspaceId: number): Promise<TokenUsageRecord[]> {
|
| 176 |
const providerSubscriptions = getProviderSubscriptionFlags()
|
| 177 |
+
const dbRecords = loadTokenDataFromDb(workspaceId, providerSubscriptions)
|
| 178 |
+
const fileRecords = await loadTokenDataFromFile(workspaceId, providerSubscriptions)
|
| 179 |
const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp)
|
| 180 |
if (combined.length > 0) {
|
| 181 |
return combined
|
| 182 |
}
|
| 183 |
|
| 184 |
// Final fallback: derive from in-memory sessions
|
| 185 |
+
return deriveFromSessions(workspaceId, providerSubscriptions)
|
| 186 |
}
|
| 187 |
|
| 188 |
/**
|
| 189 |
* Derive token usage records from OpenClaw session stores.
|
| 190 |
* Each session has totalTokens, inputTokens, outputTokens, model, etc.
|
| 191 |
*/
|
| 192 |
+
function deriveFromSessions(workspaceId: number, providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
|
| 193 |
const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age
|
| 194 |
const records: TokenUsageRecord[] = []
|
| 195 |
|
|
|
|
| 211 |
totalTokens,
|
| 212 |
cost,
|
| 213 |
operation: session.chatType || 'chat',
|
| 214 |
+
taskId: null,
|
| 215 |
+
workspaceId,
|
| 216 |
})
|
| 217 |
}
|
| 218 |
|
|
|
|
| 274 |
return records.filter(record => record.timestamp >= cutoffTime)
|
| 275 |
}
|
| 276 |
|
| 277 |
+
function loadTaskMetadataById(workspaceId: number, taskIds: number[]): Record<number, TaskCostMetadata> {
|
| 278 |
+
if (taskIds.length === 0) return {}
|
| 279 |
+
const db = getDatabase()
|
| 280 |
+
const placeholders = taskIds.map(() => '?').join(', ')
|
| 281 |
+
const rows = db.prepare(`
|
| 282 |
+
SELECT
|
| 283 |
+
t.id,
|
| 284 |
+
t.title,
|
| 285 |
+
t.status,
|
| 286 |
+
t.priority,
|
| 287 |
+
t.assigned_to,
|
| 288 |
+
t.project_id,
|
| 289 |
+
p.name as project_name,
|
| 290 |
+
p.slug as project_slug,
|
| 291 |
+
p.ticket_prefix as project_prefix,
|
| 292 |
+
t.project_ticket_no
|
| 293 |
+
FROM tasks t
|
| 294 |
+
LEFT JOIN projects p
|
| 295 |
+
ON p.id = t.project_id AND p.workspace_id = t.workspace_id
|
| 296 |
+
WHERE t.workspace_id = ?
|
| 297 |
+
AND t.id IN (${placeholders})
|
| 298 |
+
`).all(workspaceId, ...taskIds) as TaskMetadataRow[]
|
| 299 |
+
|
| 300 |
+
const out: Record<number, TaskCostMetadata> = {}
|
| 301 |
+
for (const row of rows) {
|
| 302 |
+
out[row.id] = row
|
| 303 |
+
}
|
| 304 |
+
return out
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
export async function GET(request: NextRequest) {
|
| 308 |
const auth = requireRole(request, 'viewer')
|
| 309 |
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 310 |
|
| 311 |
try {
|
| 312 |
const { searchParams } = new URL(request.url)
|
| 313 |
+
const action = (searchParams.get('action') || 'list').trim().toLowerCase()
|
| 314 |
const timeframe = searchParams.get('timeframe') || 'all'
|
| 315 |
const format = searchParams.get('format') || 'json'
|
| 316 |
|
| 317 |
+
const workspaceId = auth.user.workspace_id ?? 1
|
| 318 |
+
const tokenData = await loadTokenData(workspaceId)
|
| 319 |
const filteredData = filterByTimeframe(tokenData, timeframe)
|
| 320 |
|
| 321 |
if (action === 'list') {
|
|
|
|
| 429 |
})
|
| 430 |
}
|
| 431 |
|
| 432 |
+
if (action === 'task-costs' || action === 'task_costs' || action === 'taskcosts') {
|
| 433 |
+
const attributedTaskIds = [...new Set(
|
| 434 |
+
filteredData
|
| 435 |
+
.map((record) => record.taskId)
|
| 436 |
+
.filter((taskId): taskId is number => Number.isFinite(taskId) && Number(taskId) > 0)
|
| 437 |
+
.map((taskId) => Number(taskId))
|
| 438 |
+
)]
|
| 439 |
+
const taskMetadataById = loadTaskMetadataById(workspaceId, attributedTaskIds)
|
| 440 |
+
const report = buildTaskCostReport(
|
| 441 |
+
filteredData.map((record) => ({
|
| 442 |
+
model: record.model,
|
| 443 |
+
agentName: record.agentName || extractAgentName(record.sessionId),
|
| 444 |
+
timestamp: record.timestamp,
|
| 445 |
+
totalTokens: record.totalTokens,
|
| 446 |
+
cost: record.cost,
|
| 447 |
+
taskId: record.taskId ?? null,
|
| 448 |
+
})),
|
| 449 |
+
taskMetadataById
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
return NextResponse.json({
|
| 453 |
+
...report,
|
| 454 |
+
timeframe,
|
| 455 |
+
recordCount: filteredData.length,
|
| 456 |
+
attributedRecordCount: filteredData.filter((record) => Number.isFinite(record.taskId)).length,
|
| 457 |
+
})
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
if (action === 'export') {
|
| 461 |
const overallStats = calculateStats(filteredData)
|
| 462 |
const modelStats: Record<string, TokenStats> = {}
|
|
|
|
| 548 |
return NextResponse.json({ trends, timeframe })
|
| 549 |
}
|
| 550 |
|
| 551 |
+
return NextResponse.json({ error: 'Invalid action', action }, { status: 400 })
|
| 552 |
} catch (error) {
|
| 553 |
logger.error({ err: error }, 'Tokens API error')
|
| 554 |
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
|
|
| 561 |
|
| 562 |
try {
|
| 563 |
const body = await request.json()
|
| 564 |
+
const workspaceId = auth.user.workspace_id ?? 1
|
| 565 |
+
const { model, sessionId, inputTokens, outputTokens, operation = 'chat_completion', duration, taskId } = body
|
| 566 |
|
| 567 |
if (!model || !sessionId || typeof inputTokens !== 'number' || typeof outputTokens !== 'number') {
|
| 568 |
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
|
|
|
| 571 |
const totalTokens = inputTokens + outputTokens
|
| 572 |
const providerSubscriptions = getProviderSubscriptionFlags()
|
| 573 |
const cost = calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })
|
| 574 |
+
const parsedTaskId =
|
| 575 |
+
taskId != null && Number.isFinite(Number(taskId)) && Number(taskId) > 0
|
| 576 |
+
? Number(taskId)
|
| 577 |
+
: null
|
| 578 |
+
|
| 579 |
+
let validatedTaskId: number | null = null
|
| 580 |
+
if (parsedTaskId) {
|
| 581 |
+
const db = getDatabase()
|
| 582 |
+
const taskRow = db.prepare(
|
| 583 |
+
'SELECT id FROM tasks WHERE id = ? AND workspace_id = ?'
|
| 584 |
+
).get(parsedTaskId, workspaceId) as { id?: number } | undefined
|
| 585 |
+
if (taskRow?.id) validatedTaskId = taskRow.id
|
| 586 |
+
}
|
| 587 |
|
| 588 |
const record: TokenUsageRecord = {
|
| 589 |
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
|
|
| 596 |
totalTokens,
|
| 597 |
cost,
|
| 598 |
operation,
|
| 599 |
+
taskId: validatedTaskId,
|
| 600 |
+
workspaceId,
|
| 601 |
duration,
|
| 602 |
}
|
| 603 |
|
| 604 |
// Persist only manually posted usage records in the JSON file.
|
| 605 |
+
const existingData = await loadTokenDataFromFile(workspaceId, providerSubscriptions)
|
| 606 |
existingData.unshift(record)
|
| 607 |
|
| 608 |
if (existingData.length > 10000) {
|
src/lib/__tests__/task-costs.test.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest'
|
| 2 |
+
import { buildTaskCostReport, calculateStats, type TaskCostMetadata, type TokenCostRecord } from '@/lib/task-costs'
|
| 3 |
+
|
| 4 |
+
describe('task-cost analytics', () => {
|
| 5 |
+
it('calculates stats correctly', () => {
|
| 6 |
+
const stats = calculateStats([
|
| 7 |
+
{ model: 'a', agentName: 'alpha', timestamp: 1000, totalTokens: 100, cost: 0.1 },
|
| 8 |
+
{ model: 'b', agentName: 'alpha', timestamp: 2000, totalTokens: 300, cost: 0.3 },
|
| 9 |
+
])
|
| 10 |
+
|
| 11 |
+
expect(stats.totalTokens).toBe(400)
|
| 12 |
+
expect(stats.totalCost).toBeCloseTo(0.4)
|
| 13 |
+
expect(stats.requestCount).toBe(2)
|
| 14 |
+
expect(stats.avgTokensPerRequest).toBe(200)
|
| 15 |
+
expect(stats.avgCostPerRequest).toBeCloseTo(0.2)
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
it('builds task, agent, project and unattributed rollups', () => {
|
| 19 |
+
const records: TokenCostRecord[] = [
|
| 20 |
+
{ model: 'sonnet', agentName: 'alpha', timestamp: Date.parse('2026-03-05T01:00:00Z'), totalTokens: 100, cost: 0.1, taskId: 101 },
|
| 21 |
+
{ model: 'sonnet', agentName: 'alpha', timestamp: Date.parse('2026-03-05T02:00:00Z'), totalTokens: 150, cost: 0.15, taskId: 101 },
|
| 22 |
+
{ model: 'haiku', agentName: 'beta', timestamp: Date.parse('2026-03-05T03:00:00Z'), totalTokens: 50, cost: 0.02, taskId: 202 },
|
| 23 |
+
{ model: 'haiku', agentName: 'beta', timestamp: Date.parse('2026-03-05T03:30:00Z'), totalTokens: 75, cost: 0.03 },
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
const taskMetadata: Record<number, TaskCostMetadata> = {
|
| 27 |
+
101: {
|
| 28 |
+
id: 101,
|
| 29 |
+
title: 'Task One',
|
| 30 |
+
status: 'in_progress',
|
| 31 |
+
priority: 'high',
|
| 32 |
+
assigned_to: 'alpha',
|
| 33 |
+
project_id: 1,
|
| 34 |
+
project_name: 'Core',
|
| 35 |
+
project_slug: 'core',
|
| 36 |
+
project_prefix: 'CORE',
|
| 37 |
+
project_ticket_no: 12,
|
| 38 |
+
},
|
| 39 |
+
202: {
|
| 40 |
+
id: 202,
|
| 41 |
+
title: 'Task Two',
|
| 42 |
+
status: 'assigned',
|
| 43 |
+
priority: 'medium',
|
| 44 |
+
assigned_to: 'beta',
|
| 45 |
+
project_id: 2,
|
| 46 |
+
project_name: 'Ops',
|
| 47 |
+
project_slug: 'ops',
|
| 48 |
+
project_prefix: 'OPS',
|
| 49 |
+
project_ticket_no: 7,
|
| 50 |
+
},
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const report = buildTaskCostReport(records, taskMetadata)
|
| 54 |
+
|
| 55 |
+
expect(report.tasks).toHaveLength(2)
|
| 56 |
+
expect(report.tasks[0]?.taskId).toBe(101)
|
| 57 |
+
expect(report.tasks[0]?.stats.totalCost).toBeCloseTo(0.25)
|
| 58 |
+
expect(report.tasks[0]?.project.ticketRef).toBe('CORE-012')
|
| 59 |
+
|
| 60 |
+
expect(report.agents.alpha?.stats.totalCost).toBeCloseTo(0.25)
|
| 61 |
+
expect(report.agents.alpha?.taskIds).toEqual([101])
|
| 62 |
+
expect(report.agents.beta?.taskIds).toEqual([202])
|
| 63 |
+
|
| 64 |
+
expect(report.projects['1']?.taskCount).toBe(1)
|
| 65 |
+
expect(report.projects['2']?.taskCount).toBe(1)
|
| 66 |
+
|
| 67 |
+
expect(report.summary.totalCost).toBeCloseTo(0.27)
|
| 68 |
+
expect(report.unattributed.totalCost).toBeCloseTo(0.03)
|
| 69 |
+
})
|
| 70 |
+
})
|
src/lib/migrations.ts
CHANGED
|
@@ -755,7 +755,27 @@ const migrations: Migration[] = [
|
|
| 755 |
}
|
| 756 |
},
|
| 757 |
{
|
| 758 |
-
id: '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
up: (db) => {
|
| 760 |
const hasTasks = db
|
| 761 |
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`)
|
|
|
|
| 755 |
}
|
| 756 |
},
|
| 757 |
{
|
| 758 |
+
id: '025_token_usage_task_attribution',
|
| 759 |
+
up: (db) => {
|
| 760 |
+
const hasTokenUsageTable = db
|
| 761 |
+
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'token_usage'`)
|
| 762 |
+
.get() as { ok?: number } | undefined
|
| 763 |
+
|
| 764 |
+
if (!hasTokenUsageTable?.ok) return
|
| 765 |
+
|
| 766 |
+
const cols = db.prepare(`PRAGMA table_info(token_usage)`).all() as Array<{ name: string }>
|
| 767 |
+
const hasCol = (name: string) => cols.some((c) => c.name === name)
|
| 768 |
+
|
| 769 |
+
if (!hasCol('task_id')) {
|
| 770 |
+
db.exec(`ALTER TABLE token_usage ADD COLUMN task_id INTEGER`)
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_task_id ON token_usage(task_id)`)
|
| 774 |
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_task_time ON token_usage(workspace_id, task_id, created_at)`)
|
| 775 |
+
}
|
| 776 |
+
},
|
| 777 |
+
{
|
| 778 |
+
id: '026_task_outcome_tracking',
|
| 779 |
up: (db) => {
|
| 780 |
const hasTasks = db
|
| 781 |
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`)
|
src/lib/task-costs.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface TokenCostRecord {
|
| 2 |
+
model: string
|
| 3 |
+
agentName: string
|
| 4 |
+
timestamp: number
|
| 5 |
+
totalTokens: number
|
| 6 |
+
cost: number
|
| 7 |
+
taskId?: number | null
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export interface TokenStats {
|
| 11 |
+
totalTokens: number
|
| 12 |
+
totalCost: number
|
| 13 |
+
requestCount: number
|
| 14 |
+
avgTokensPerRequest: number
|
| 15 |
+
avgCostPerRequest: number
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export interface TaskCostMetadata {
|
| 19 |
+
id: number
|
| 20 |
+
title: string
|
| 21 |
+
status: string
|
| 22 |
+
priority: string
|
| 23 |
+
assigned_to?: string | null
|
| 24 |
+
project_id?: number | null
|
| 25 |
+
project_name?: string | null
|
| 26 |
+
project_slug?: string | null
|
| 27 |
+
project_ticket_no?: number | null
|
| 28 |
+
project_prefix?: string | null
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export interface TaskCostEntry {
|
| 32 |
+
taskId: number
|
| 33 |
+
title: string
|
| 34 |
+
status: string
|
| 35 |
+
priority: string
|
| 36 |
+
assignedTo?: string | null
|
| 37 |
+
project: {
|
| 38 |
+
id?: number | null
|
| 39 |
+
name?: string | null
|
| 40 |
+
slug?: string | null
|
| 41 |
+
ticketRef?: string | null
|
| 42 |
+
}
|
| 43 |
+
stats: TokenStats
|
| 44 |
+
models: Record<string, TokenStats>
|
| 45 |
+
timeline: Array<{ date: string; cost: number; tokens: number }>
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export interface AgentTaskCostEntry {
|
| 49 |
+
stats: TokenStats
|
| 50 |
+
taskCount: number
|
| 51 |
+
taskIds: number[]
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export interface ProjectTaskCostEntry {
|
| 55 |
+
stats: TokenStats
|
| 56 |
+
taskCount: number
|
| 57 |
+
taskIds: number[]
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export interface TaskCostReport {
|
| 61 |
+
summary: TokenStats
|
| 62 |
+
tasks: TaskCostEntry[]
|
| 63 |
+
agents: Record<string, AgentTaskCostEntry>
|
| 64 |
+
projects: Record<string, ProjectTaskCostEntry>
|
| 65 |
+
unattributed: TokenStats
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function calculateStats(records: TokenCostRecord[]): TokenStats {
|
| 69 |
+
if (records.length === 0) {
|
| 70 |
+
return {
|
| 71 |
+
totalTokens: 0,
|
| 72 |
+
totalCost: 0,
|
| 73 |
+
requestCount: 0,
|
| 74 |
+
avgTokensPerRequest: 0,
|
| 75 |
+
avgCostPerRequest: 0,
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const totalTokens = records.reduce((sum, r) => sum + r.totalTokens, 0)
|
| 80 |
+
const totalCost = records.reduce((sum, r) => sum + r.cost, 0)
|
| 81 |
+
const requestCount = records.length
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
totalTokens,
|
| 85 |
+
totalCost,
|
| 86 |
+
requestCount,
|
| 87 |
+
avgTokensPerRequest: Math.round(totalTokens / requestCount),
|
| 88 |
+
avgCostPerRequest: totalCost / requestCount,
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function groupByModel(records: TokenCostRecord[]): Record<string, TokenStats> {
|
| 93 |
+
const modelGroups: Record<string, TokenCostRecord[]> = {}
|
| 94 |
+
for (const record of records) {
|
| 95 |
+
if (!modelGroups[record.model]) modelGroups[record.model] = []
|
| 96 |
+
modelGroups[record.model].push(record)
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const result: Record<string, TokenStats> = {}
|
| 100 |
+
for (const [model, modelRecords] of Object.entries(modelGroups)) {
|
| 101 |
+
result[model] = calculateStats(modelRecords)
|
| 102 |
+
}
|
| 103 |
+
return result
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function buildTimeline(records: TokenCostRecord[]): Array<{ date: string; cost: number; tokens: number }> {
|
| 107 |
+
const byDate: Record<string, { cost: number; tokens: number }> = {}
|
| 108 |
+
|
| 109 |
+
for (const record of records) {
|
| 110 |
+
const date = new Date(record.timestamp).toISOString().split('T')[0]
|
| 111 |
+
if (!byDate[date]) {
|
| 112 |
+
byDate[date] = { cost: 0, tokens: 0 }
|
| 113 |
+
}
|
| 114 |
+
byDate[date].cost += record.cost
|
| 115 |
+
byDate[date].tokens += record.totalTokens
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
return Object.entries(byDate)
|
| 119 |
+
.sort(([a], [b]) => a.localeCompare(b))
|
| 120 |
+
.map(([date, totals]) => ({ date, ...totals }))
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function formatTicketRef(prefix?: string | null, num?: number | null): string | null {
|
| 124 |
+
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return null
|
| 125 |
+
return `${prefix}-${String(num).padStart(3, '0')}`
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export function buildTaskCostReport(records: TokenCostRecord[], taskMetadata: Record<number, TaskCostMetadata>): TaskCostReport {
|
| 129 |
+
const attributedRecords = records.filter((record) => Number.isFinite(record.taskId))
|
| 130 |
+
const unattributedRecords = records.filter((record) => !Number.isFinite(record.taskId))
|
| 131 |
+
|
| 132 |
+
const byTask: Record<number, TokenCostRecord[]> = {}
|
| 133 |
+
for (const record of attributedRecords) {
|
| 134 |
+
const taskId = Number(record.taskId)
|
| 135 |
+
if (!taskMetadata[taskId]) continue
|
| 136 |
+
if (!byTask[taskId]) byTask[taskId] = []
|
| 137 |
+
byTask[taskId].push(record)
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const tasks: TaskCostEntry[] = Object.entries(byTask)
|
| 141 |
+
.map(([taskIdRaw, taskRecords]) => {
|
| 142 |
+
const taskId = Number(taskIdRaw)
|
| 143 |
+
const meta = taskMetadata[taskId]
|
| 144 |
+
return {
|
| 145 |
+
taskId,
|
| 146 |
+
title: meta.title,
|
| 147 |
+
status: meta.status,
|
| 148 |
+
priority: meta.priority,
|
| 149 |
+
assignedTo: meta.assigned_to || null,
|
| 150 |
+
project: {
|
| 151 |
+
id: meta.project_id ?? null,
|
| 152 |
+
name: meta.project_name ?? null,
|
| 153 |
+
slug: meta.project_slug ?? null,
|
| 154 |
+
ticketRef: formatTicketRef(meta.project_prefix, meta.project_ticket_no),
|
| 155 |
+
},
|
| 156 |
+
stats: calculateStats(taskRecords),
|
| 157 |
+
models: groupByModel(taskRecords),
|
| 158 |
+
timeline: buildTimeline(taskRecords),
|
| 159 |
+
}
|
| 160 |
+
})
|
| 161 |
+
.sort((a, b) => b.stats.totalCost - a.stats.totalCost)
|
| 162 |
+
|
| 163 |
+
const byAgent: Record<string, TokenCostRecord[]> = {}
|
| 164 |
+
for (const record of attributedRecords) {
|
| 165 |
+
const taskId = Number(record.taskId)
|
| 166 |
+
if (!taskMetadata[taskId]) continue
|
| 167 |
+
if (!byAgent[record.agentName]) byAgent[record.agentName] = []
|
| 168 |
+
byAgent[record.agentName].push(record)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const agentTaskIds: Record<string, Set<number>> = {}
|
| 172 |
+
for (const task of tasks) {
|
| 173 |
+
const taskRecords = byTask[task.taskId] || []
|
| 174 |
+
for (const record of taskRecords) {
|
| 175 |
+
const agent = record.agentName
|
| 176 |
+
if (!agentTaskIds[agent]) agentTaskIds[agent] = new Set()
|
| 177 |
+
agentTaskIds[agent].add(task.taskId)
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
const agents: Record<string, AgentTaskCostEntry> = {}
|
| 182 |
+
for (const [agent, agentRecords] of Object.entries(byAgent)) {
|
| 183 |
+
const taskIds = [...(agentTaskIds[agent] || new Set<number>())].sort((a, b) => a - b)
|
| 184 |
+
agents[agent] = {
|
| 185 |
+
stats: calculateStats(agentRecords),
|
| 186 |
+
taskCount: taskIds.length,
|
| 187 |
+
taskIds,
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
const byProject: Record<string, TokenCostRecord[]> = {}
|
| 192 |
+
const projectTaskIds: Record<string, Set<number>> = {}
|
| 193 |
+
for (const record of attributedRecords) {
|
| 194 |
+
const taskId = Number(record.taskId)
|
| 195 |
+
const meta = taskMetadata[taskId]
|
| 196 |
+
if (!meta) continue
|
| 197 |
+
const key = meta.project_id ? String(meta.project_id) : 'unscoped'
|
| 198 |
+
if (!byProject[key]) byProject[key] = []
|
| 199 |
+
byProject[key].push(record)
|
| 200 |
+
if (!projectTaskIds[key]) projectTaskIds[key] = new Set()
|
| 201 |
+
projectTaskIds[key].add(taskId)
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
const projects: Record<string, ProjectTaskCostEntry> = {}
|
| 205 |
+
for (const [projectKey, projectRecords] of Object.entries(byProject)) {
|
| 206 |
+
const taskIds = [...(projectTaskIds[projectKey] || new Set<number>())].sort((a, b) => a - b)
|
| 207 |
+
projects[projectKey] = {
|
| 208 |
+
stats: calculateStats(projectRecords),
|
| 209 |
+
taskCount: taskIds.length,
|
| 210 |
+
taskIds,
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
return {
|
| 215 |
+
summary: calculateStats(attributedRecords.filter((record) => Number.isFinite(record.taskId) && taskMetadata[Number(record.taskId)])),
|
| 216 |
+
tasks,
|
| 217 |
+
agents,
|
| 218 |
+
projects,
|
| 219 |
+
unattributed: calculateStats(unattributedRecords),
|
| 220 |
+
}
|
| 221 |
+
}
|
tests/agent-costs.spec.ts
CHANGED
|
@@ -97,4 +97,64 @@ test.describe('Agent Costs API', () => {
|
|
| 97 |
const res = await request.get('/api/tokens?action=agent-costs&timeframe=all')
|
| 98 |
expect(res.status()).toBe(401)
|
| 99 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
})
|
|
|
|
| 97 |
const res = await request.get('/api/tokens?action=agent-costs&timeframe=all')
|
| 98 |
expect(res.status()).toBe(401)
|
| 99 |
})
|
| 100 |
+
|
| 101 |
+
test('GET action=task-costs returns task-level attribution and unattributed rollup', async ({ request }) => {
|
| 102 |
+
const agentName = `e2e-taskcost-agent-${Date.now()}`
|
| 103 |
+
const createTaskRes = await request.post('/api/tasks', {
|
| 104 |
+
headers: API_KEY_HEADER,
|
| 105 |
+
data: {
|
| 106 |
+
title: `E2E Task Cost ${Date.now()}`,
|
| 107 |
+
description: 'Task cost attribution test',
|
| 108 |
+
assigned_to: agentName,
|
| 109 |
+
},
|
| 110 |
+
})
|
| 111 |
+
expect(createTaskRes.status()).toBe(201)
|
| 112 |
+
const createdTask = await createTaskRes.json()
|
| 113 |
+
const taskId = createdTask.task.id as number
|
| 114 |
+
|
| 115 |
+
const postAttributed = await request.post('/api/tokens', {
|
| 116 |
+
headers: API_KEY_HEADER,
|
| 117 |
+
data: {
|
| 118 |
+
model: 'claude-sonnet-4',
|
| 119 |
+
sessionId: `${agentName}:chat`,
|
| 120 |
+
inputTokens: 300,
|
| 121 |
+
outputTokens: 100,
|
| 122 |
+
taskId,
|
| 123 |
+
},
|
| 124 |
+
})
|
| 125 |
+
expect(postAttributed.status()).toBe(200)
|
| 126 |
+
|
| 127 |
+
const postUnattributed = await request.post('/api/tokens', {
|
| 128 |
+
headers: API_KEY_HEADER,
|
| 129 |
+
data: {
|
| 130 |
+
model: 'claude-haiku-3.5',
|
| 131 |
+
sessionId: `${agentName}:chat`,
|
| 132 |
+
inputTokens: 50,
|
| 133 |
+
outputTokens: 50,
|
| 134 |
+
},
|
| 135 |
+
})
|
| 136 |
+
expect(postUnattributed.status()).toBe(200)
|
| 137 |
+
|
| 138 |
+
const res = await request.get('/api/tokens?action=task-costs&timeframe=hour', {
|
| 139 |
+
headers: API_KEY_HEADER,
|
| 140 |
+
})
|
| 141 |
+
const responseText = await res.text()
|
| 142 |
+
expect(res.status(), responseText).toBe(200)
|
| 143 |
+
const body = JSON.parse(responseText)
|
| 144 |
+
|
| 145 |
+
expect(body).toHaveProperty('summary')
|
| 146 |
+
expect(body).toHaveProperty('tasks')
|
| 147 |
+
expect(body).toHaveProperty('agents')
|
| 148 |
+
expect(body).toHaveProperty('projects')
|
| 149 |
+
expect(body).toHaveProperty('unattributed')
|
| 150 |
+
expect(Array.isArray(body.tasks)).toBe(true)
|
| 151 |
+
|
| 152 |
+
const matchingTask = body.tasks.find((task: any) => task.taskId === taskId)
|
| 153 |
+
expect(matchingTask).toBeTruthy()
|
| 154 |
+
expect(matchingTask.title).toBe(createdTask.task.title)
|
| 155 |
+
expect(matchingTask.stats.totalTokens).toBe(400)
|
| 156 |
+
expect(matchingTask.stats.requestCount).toBeGreaterThanOrEqual(1)
|
| 157 |
+
expect(body.agents[agentName].taskIds).toContain(taskId)
|
| 158 |
+
expect(body.unattributed.requestCount).toBeGreaterThanOrEqual(1)
|
| 159 |
+
})
|
| 160 |
})
|