* feat(PBI-78): cost-analysis data layer (T-902)
- New lib/insights/cost-analysis.ts with 5 query functions:
getCostKpi, getCostByDay, getCostByModel, getCostByKind, getCacheEfficiency
- Period type: 7d | 30d | 90d | mtd
- Cost formula reused from token-stats.ts (input + output + cache + thinking)
- Cache savings: cache_read_tokens × (input_price - cache_read_price) / 1M
- Plan: docs/plans/PBI-78-cost-analysis-widget.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-78): cost-analysis UI component (T-903)
- New app/(app)/insights/components/cost-analysis.tsx
- Period selector (7d/30d/90d/MTD) via URL ?period= with useTransition + router.replace
- KPI strip: total cost, avg per day, cache savings, top model
- 2x2 chart grid: daily cost (BarChart), per-model + per-kind (vertical BarCharts), cache efficiency (PieChart)
- Empty state for kpi.jobCount === 0
- Uses MD3 tokens (var(--chart-N), var(--status-done))
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-78): wire CostAnalysisCard onto insights page (T-904)
- Parse ?period= from searchParams (default 30d, validates against 7d/30d/90d/mtd)
- Parallel-fetch 5 cost queries via Promise.all alongside existing widgets
- New "Cost analyse" section between Sprint Health and Plan-quality
- Existing TokenUsageCard ("Token gebruik" section) stays as sprint detail
verify (lint+typecheck+test) and build pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
291 lines
8.7 KiB
TypeScript
291 lines
8.7 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
|
|
|
export type Period = '7d' | '30d' | '90d' | 'mtd'
|
|
|
|
export interface CostKpi {
|
|
totalCostUsd: number
|
|
totalTokens: number
|
|
jobCount: number
|
|
avgPerDayUsd: number
|
|
cacheSavingsUsd: number
|
|
topModelId: string | null
|
|
topModelCostUsd: number
|
|
}
|
|
|
|
export interface CostByDayRow {
|
|
day: string
|
|
costUsd: number
|
|
}
|
|
|
|
export interface CostByModelRow {
|
|
modelId: string
|
|
costUsd: number
|
|
jobCount: number
|
|
}
|
|
|
|
export interface CostByKindRow {
|
|
kind: string
|
|
costUsd: number
|
|
jobCount: number
|
|
}
|
|
|
|
export interface CacheEfficiency {
|
|
cacheReadTokens: number
|
|
uncachedInputTokens: number
|
|
cacheHitRatio: number
|
|
savingsUsd: number
|
|
spentOnCacheWriteUsd: number
|
|
}
|
|
|
|
function periodToDays(period: Period, now: Date = new Date()): number {
|
|
switch (period) {
|
|
case '7d':
|
|
return 7
|
|
case '30d':
|
|
return 30
|
|
case '90d':
|
|
return 90
|
|
case 'mtd':
|
|
return now.getUTCDate()
|
|
}
|
|
}
|
|
|
|
function periodStart(period: Period, now: Date = new Date()): Date {
|
|
if (period === 'mtd') {
|
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
|
}
|
|
const days = periodToDays(period, now)
|
|
return new Date(now.getTime() - days * 86_400_000)
|
|
}
|
|
|
|
type RawKpiRow = {
|
|
total_cost: number | null
|
|
total_tokens: bigint
|
|
job_count: bigint
|
|
cache_savings: number | null
|
|
}
|
|
|
|
type RawTopModelRow = {
|
|
model_id: string | null
|
|
cost: number | null
|
|
}
|
|
|
|
export async function getCostKpi(userId: string, period: Period): Promise<CostKpi> {
|
|
const start = periodStart(period)
|
|
const days = Math.max(periodToDays(period), 1)
|
|
|
|
const [kpiRows, topModelRows] = await Promise.all([
|
|
prisma.$queryRaw<RawKpiRow[]>`
|
|
SELECT
|
|
SUM(
|
|
cj.input_tokens * mp.input_price_per_1m / 1000000.0
|
|
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
|
|
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
|
|
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
|
|
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
|
|
) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost,
|
|
COALESCE(SUM(
|
|
cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens
|
|
+ COALESCE(cj.actual_thinking_tokens, 0)
|
|
), 0) AS total_tokens,
|
|
COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count,
|
|
SUM(
|
|
cj.cache_read_tokens * (mp.input_price_per_1m - mp.cache_read_price_per_1m) / 1000000.0
|
|
) FILTER (WHERE cj.cache_read_tokens IS NOT NULL) AS cache_savings
|
|
FROM claude_jobs cj
|
|
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
|
|
WHERE cj.user_id = ${userId}
|
|
AND cj.status = 'DONE'
|
|
AND cj.finished_at >= ${start}
|
|
`,
|
|
prisma.$queryRaw<RawTopModelRow[]>`
|
|
SELECT
|
|
cj.model_id,
|
|
SUM(
|
|
cj.input_tokens * mp.input_price_per_1m / 1000000.0
|
|
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
|
|
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
|
|
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
|
|
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
|
|
) AS cost
|
|
FROM claude_jobs cj
|
|
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
|
|
WHERE cj.user_id = ${userId}
|
|
AND cj.status = 'DONE'
|
|
AND cj.finished_at >= ${start}
|
|
AND cj.input_tokens IS NOT NULL
|
|
AND cj.model_id IS NOT NULL
|
|
GROUP BY cj.model_id
|
|
ORDER BY cost DESC NULLS LAST
|
|
LIMIT 1
|
|
`,
|
|
])
|
|
|
|
const kpi = kpiRows[0]
|
|
const totalCost = Number(kpi?.total_cost ?? 0)
|
|
const top = topModelRows[0]
|
|
|
|
return {
|
|
totalCostUsd: totalCost,
|
|
totalTokens: Number(kpi?.total_tokens ?? 0),
|
|
jobCount: Number(kpi?.job_count ?? 0),
|
|
avgPerDayUsd: totalCost / days,
|
|
cacheSavingsUsd: Number(kpi?.cache_savings ?? 0),
|
|
topModelId: top?.model_id ?? null,
|
|
topModelCostUsd: Number(top?.cost ?? 0),
|
|
}
|
|
}
|
|
|
|
type RawDayRow = {
|
|
day: Date
|
|
cost: number | null
|
|
}
|
|
|
|
export async function getCostByDay(userId: string, period: Period): Promise<CostByDayRow[]> {
|
|
const start = periodStart(period)
|
|
|
|
const rows = await prisma.$queryRaw<RawDayRow[]>`
|
|
SELECT
|
|
DATE(cj.finished_at) AS day,
|
|
SUM(
|
|
cj.input_tokens * mp.input_price_per_1m / 1000000.0
|
|
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
|
|
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
|
|
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
|
|
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
|
|
) FILTER (WHERE cj.input_tokens IS NOT NULL) AS cost
|
|
FROM claude_jobs cj
|
|
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
|
|
WHERE cj.user_id = ${userId}
|
|
AND cj.status = 'DONE'
|
|
AND cj.finished_at >= ${start}
|
|
GROUP BY DATE(cj.finished_at)
|
|
ORDER BY day ASC
|
|
`
|
|
|
|
return rows.map(r => ({
|
|
day: r.day.toISOString().slice(0, 10),
|
|
costUsd: Number(r.cost ?? 0),
|
|
}))
|
|
}
|
|
|
|
type RawModelRow = {
|
|
model_id: string
|
|
cost: number | null
|
|
job_count: bigint
|
|
}
|
|
|
|
export async function getCostByModel(userId: string, period: Period): Promise<CostByModelRow[]> {
|
|
const start = periodStart(period)
|
|
|
|
const rows = await prisma.$queryRaw<RawModelRow[]>`
|
|
SELECT
|
|
cj.model_id,
|
|
SUM(
|
|
cj.input_tokens * mp.input_price_per_1m / 1000000.0
|
|
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
|
|
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
|
|
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
|
|
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
|
|
) AS cost,
|
|
COUNT(*) AS job_count
|
|
FROM claude_jobs cj
|
|
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
|
|
WHERE cj.user_id = ${userId}
|
|
AND cj.status = 'DONE'
|
|
AND cj.finished_at >= ${start}
|
|
AND cj.input_tokens IS NOT NULL
|
|
AND cj.model_id IS NOT NULL
|
|
GROUP BY cj.model_id
|
|
ORDER BY cost DESC NULLS LAST
|
|
LIMIT 5
|
|
`
|
|
|
|
return rows.map(r => ({
|
|
modelId: r.model_id,
|
|
costUsd: Number(r.cost ?? 0),
|
|
jobCount: Number(r.job_count),
|
|
}))
|
|
}
|
|
|
|
type RawKindRow = {
|
|
kind: string
|
|
cost: number | null
|
|
job_count: bigint
|
|
}
|
|
|
|
export async function getCostByKind(userId: string, period: Period): Promise<CostByKindRow[]> {
|
|
const start = periodStart(period)
|
|
|
|
const rows = await prisma.$queryRaw<RawKindRow[]>`
|
|
SELECT
|
|
cj.kind::text AS kind,
|
|
SUM(
|
|
cj.input_tokens * mp.input_price_per_1m / 1000000.0
|
|
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
|
|
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
|
|
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
|
|
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
|
|
) AS cost,
|
|
COUNT(*) AS job_count
|
|
FROM claude_jobs cj
|
|
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
|
|
WHERE cj.user_id = ${userId}
|
|
AND cj.status = 'DONE'
|
|
AND cj.finished_at >= ${start}
|
|
AND cj.input_tokens IS NOT NULL
|
|
GROUP BY cj.kind
|
|
ORDER BY cost DESC NULLS LAST
|
|
LIMIT 5
|
|
`
|
|
|
|
return rows.map(r => ({
|
|
kind: r.kind,
|
|
costUsd: Number(r.cost ?? 0),
|
|
jobCount: Number(r.job_count),
|
|
}))
|
|
}
|
|
|
|
type RawCacheRow = {
|
|
cache_read_tokens: bigint
|
|
uncached_input_tokens: bigint
|
|
savings: number | null
|
|
cache_write_cost: number | null
|
|
}
|
|
|
|
export async function getCacheEfficiency(
|
|
userId: string,
|
|
period: Period,
|
|
): Promise<CacheEfficiency> {
|
|
const start = periodStart(period)
|
|
|
|
const rows = await prisma.$queryRaw<RawCacheRow[]>`
|
|
SELECT
|
|
COALESCE(SUM(cj.cache_read_tokens), 0) AS cache_read_tokens,
|
|
COALESCE(SUM(cj.input_tokens), 0) AS uncached_input_tokens,
|
|
SUM(
|
|
cj.cache_read_tokens * (mp.input_price_per_1m - mp.cache_read_price_per_1m) / 1000000.0
|
|
) FILTER (WHERE cj.cache_read_tokens IS NOT NULL) AS savings,
|
|
SUM(
|
|
cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
|
|
) FILTER (WHERE cj.cache_write_tokens IS NOT NULL) AS cache_write_cost
|
|
FROM claude_jobs cj
|
|
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
|
|
WHERE cj.user_id = ${userId}
|
|
AND cj.status = 'DONE'
|
|
AND cj.finished_at >= ${start}
|
|
`
|
|
|
|
const r = rows[0]
|
|
const cacheReadTokens = Number(r?.cache_read_tokens ?? 0)
|
|
const uncachedInputTokens = Number(r?.uncached_input_tokens ?? 0)
|
|
const totalInputLike = cacheReadTokens + uncachedInputTokens
|
|
|
|
return {
|
|
cacheReadTokens,
|
|
uncachedInputTokens,
|
|
cacheHitRatio: totalInputLike > 0 ? cacheReadTokens / totalInputLike : 0,
|
|
savingsUsd: Number(r?.savings ?? 0),
|
|
spentOnCacheWriteUsd: Number(r?.cache_write_cost ?? 0),
|
|
}
|
|
}
|