From d0bebda3acba32dfbb5a3805390180a1cb582307 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 11:16:22 +0200 Subject: [PATCH] feat(PBI-67/ST-1300): cost-attribution voor thinking-tokens + admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-792: token-stats + token-history rekenen actual_thinking_tokens nu mee in de totale kosten (tegen input-rate, conform Anthropic billing). COALESCE-veilig zodat oude rijen 0 bijdragen i.p.v. NaN. Nieuwe export `getTokenStatsByKind` aggregeert tokens en kosten per ClaudeJob.kind zodat we relatieve uitgaven van IDEA_GRILL/IDEA_MAKE_PLAN/PLAN_CHAT/ TASK_IMPLEMENTATION/SPRINT_IMPLEMENTATION kunnen zien. T-793: admin/jobs Kosten-tabel toont: - Nieuwe kolom 'Thinking' (aantal verbruikte thinking-tokens) - Mismatch-marker (rood) als requested_model afwijkt van actuele model_id — duidt op een worker die de CLI-flag niet doorgaf. Tooltip toont aangevraagd model. Geen Sentry/log-noise. Page-level cost-berekening volgt dezelfde formule (input_price × thinking_tokens). 563 tests groen. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/admin/jobs/page.tsx | 7 +++- components/admin/jobs-table.tsx | 20 +++++++++- lib/insights/token-history.ts | 12 ++++-- lib/insights/token-stats.ts | 68 ++++++++++++++++++++++++++++++++- 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/app/(app)/admin/jobs/page.tsx b/app/(app)/admin/jobs/page.tsx index 76d0825..8a3ba85 100644 --- a/app/(app)/admin/jobs/page.tsx +++ b/app/(app)/admin/jobs/page.tsx @@ -21,6 +21,10 @@ export default async function AdminJobsPage() { output_tokens: true, cache_read_tokens: true, cache_write_tokens: true, + actual_thinking_tokens: true, + requested_model: true, + requested_thinking_budget: true, + requested_permission_mode: true, user: { select: { username: true } }, product: { select: { name: true } }, }, @@ -36,7 +40,8 @@ export default async function AdminJobsPage() { (job.input_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 + (job.output_tokens ?? 0) * Number(p.output_price_per_1m) / 1_000_000 + (job.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m) / 1_000_000 + - (job.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m) / 1_000_000 + (job.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m) / 1_000_000 + + (job.actual_thinking_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 return { ...job, cost_usd: cost } }) diff --git a/components/admin/jobs-table.tsx b/components/admin/jobs-table.tsx index 3b1c312..cddf90b 100644 --- a/components/admin/jobs-table.tsx +++ b/components/admin/jobs-table.tsx @@ -24,6 +24,10 @@ type Job = { pr_url: string | null error: string | null model_id: string | null + actual_thinking_tokens: number | null + requested_model: string | null + requested_thinking_budget: number | null + requested_permission_mode: string | null cost_usd: number | null } @@ -131,13 +135,24 @@ function CostRow({ job }: { job: Job }) { function handleCancel() { startTransition(() => cancelJobAction(job.id)) } function handleDelete() { startTransition(() => deleteJobAction(job.id)) } const costLabel = job.cost_usd != null ? `$${job.cost_usd.toFixed(4)}` : '—' + const thinkingLabel = job.actual_thinking_tokens != null ? job.actual_thinking_tokens.toLocaleString('nl-NL') : '—' + const modelMismatch = job.requested_model != null && job.model_id != null && job.requested_model !== job.model_id + const modelTitle = job.requested_model + ? `Aangevraagd: ${job.requested_model}${modelMismatch ? ' (mismatch met actueel)' : ''}` + : undefined return ( {job.id.slice(0, 8)} {job.user.username} {job.product.name} {KIND_LABEL[job.kind] ?? job.kind} - {job.model_id ?? '—'} + + {job.model_id ?? '—'} + + {thinkingLabel} {costLabel} {new Date(job.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} @@ -164,6 +179,7 @@ function CostsTable({ jobs }: { jobs: Job[] }) { Product Type Model + Thinking Kosten (USD) Aangemaakt Acties @@ -172,7 +188,7 @@ function CostsTable({ jobs }: { jobs: Job[] }) { {jobs.length === 0 && ( - + Geen jobs gevonden diff --git a/lib/insights/token-history.ts b/lib/insights/token-history.ts index 75674b0..42f4ecc 100644 --- a/lib/insights/token-history.ts +++ b/lib/insights/token-history.ts @@ -57,12 +57,13 @@ export async function getSprintTokenHistory( sp.id AS sprint_id, sp.code AS sprint_code, sp.sprint_goal, - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens, + 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, 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, COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count FROM claude_jobs cj @@ -82,12 +83,13 @@ export async function getSprintTokenHistory( sp.id AS sprint_id, sp.code AS sprint_code, sp.sprint_goal, - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens, + 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, 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, COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count FROM claude_jobs cj @@ -118,12 +120,13 @@ export async function getDayTokenData(userId: string, sprintId: string): Promise const rows = await prisma.$queryRaw` SELECT DATE(cj.finished_at) AS day, - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens, + 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, 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 FROM claude_jobs cj JOIN tasks t ON cj.task_id = t.id @@ -152,12 +155,13 @@ export async function getPbiTokenAggregates(userId: string, sprintId: string): P p.id AS pbi_id, p.code AS pbi_code, p.title AS pbi_title, - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens, + 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, 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 FROM claude_jobs cj JOIN tasks t ON cj.task_id = t.id diff --git a/lib/insights/token-stats.ts b/lib/insights/token-stats.ts index 312c822..41d4a7c 100644 --- a/lib/insights/token-stats.ts +++ b/lib/insights/token-stats.ts @@ -16,10 +16,18 @@ export interface TokenJobRow { outputTokens: number | null cacheReadTokens: number | null cacheWriteTokens: number | null + thinkingTokens: number | null costUsd: number | null durationSeconds: number | null } +export interface TokenStatsByKindRow { + kind: string + jobCount: number + totalTokens: number + totalCostUsd: number +} + export interface TokenStatsResult { kpi: TokenKpi jobs: TokenJobRow[] @@ -41,10 +49,18 @@ type RawJobRow = { output_tokens: number | null cache_read_tokens: number | null cache_write_tokens: number | null + actual_thinking_tokens: number | null cost_usd: number | null duration_seconds: number | null } +type RawByKindRow = { + kind: string + job_count: bigint + total_tokens: bigint + total_cost: number | null +} + const EMPTY_KPI: TokenKpi = { totalTokens: 0, totalCostUsd: 0, avgCostPerJob: 0, jobCount: 0 } export async function getTokenStats(userId: string, sprintId: string): Promise { @@ -53,18 +69,20 @@ export async function getTokenStats(userId: string, sprintId: string): Promise` SELECT - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens, + 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, 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, AVG( 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 avg_cost, COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count FROM claude_jobs cj @@ -85,11 +103,13 @@ export async function getTokenStats(userId: string, sprintId: string): Promise { + if (!sprintId) return [] + + const rows = await prisma.$queryRaw` + SELECT + cj.kind::text AS kind, + COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count, + 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, + 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 + FROM claude_jobs cj + JOIN tasks t ON cj.task_id = t.id + JOIN stories s ON t.story_id = s.id + LEFT JOIN model_prices mp ON mp.model_id = cj.model_id + WHERE cj.user_id = ${userId} + AND s.sprint_id = ${sprintId} + AND cj.status = 'DONE' + GROUP BY cj.kind + ORDER BY total_cost DESC NULLS LAST + ` + + return rows.map((r) => ({ + kind: r.kind, + jobCount: Number(r.job_count), + totalTokens: Number(r.total_tokens), + totalCostUsd: Number(r.total_cost ?? 0), + })) +}