diff --git a/app/renderer/lib/investigation.test.ts b/app/renderer/lib/investigation.test.ts index f84d425f..9427b1f1 100644 --- a/app/renderer/lib/investigation.test.ts +++ b/app/renderer/lib/investigation.test.ts @@ -209,6 +209,7 @@ describe('contribution math', () => { cost: 0.9, contributions: { segments: [segment({ models: { 'Sonnet 4.5': 0.3, 'GPT-5.5': 0.6 }, + modelUsage: { 'Sonnet 4.5': { calls: 1, inputTokens: 100, outputTokens: 20 }, 'GPT-5.5': { calls: 1, inputTokens: 200, outputTokens: 40 } }, cost: 0.9, })] }, }) @@ -233,6 +234,29 @@ describe('contribution math', () => { expect(andResult.cost).toBeCloseTo(0.9, 10) }) + it('uses actual per-model requests and tokens, including zero-cost usage', () => { + const subject = row({ sessionId: 'priced-differently', cost: 10, contributions: { segments: [segment({ + cost: 10, calls: 3, inputTokens: 1050, + models: { expensive: 9, cheap: 1, free: 0 }, + modelUsage: { + expensive: { calls: 1, inputTokens: 100, outputTokens: 0 }, + cheap: { calls: 1, inputTokens: 900, outputTokens: 0 }, + free: { calls: 1, inputTokens: 50, outputTokens: 0 }, + }, + })] } }) + expect(applyInvestigation([subject], modelFilters(['expensive']))).toMatchObject({ cost: 9, calls: 1, tokens: 100 }) + expect(applyInvestigation([subject], modelFilters(['free']))).toMatchObject({ cost: 0, calls: 1, tokens: 50 }) + expect(applyInvestigation([subject], modelFilters(['expensive', 'cheap', 'free']))).toMatchObject({ cost: 10, calls: 3, tokens: 1050 }) + }) + + it('discloses old model payloads without counting rows excluded by provider', () => { + const legacy = row({ sessionId: 'legacy', cost: 2, contributions: { segments: [segment({ cost: 2, models: { old: 2 } })] } }) + const otherProvider = row({ sessionId: 'other', provider: 'codex', cost: 3 }) + const result = applyInvestigation([legacy, otherProvider], withFilterValue(modelFilters(['old']), 'providers', 'claude')) + expect(result.included).toEqual([]) + expect(result.unattributable).toBe(1) + }) + it('rows without contribution segments are excluded and disclosed, not silently zero', () => { const plain = row({ sessionId: 'plain', cost: 3 }) const result = applyInvestigation([plain], categoryFilters('coding')) diff --git a/app/renderer/lib/investigation.ts b/app/renderer/lib/investigation.ts index cddb4068..af6d1957 100644 --- a/app/renderer/lib/investigation.ts +++ b/app/renderer/lib/investigation.ts @@ -215,17 +215,13 @@ function segmentMatchesDimensions(segment: ContributionSegment, filters: Investi * Session-level dimensions (providers/projects/sessions) gate the whole row. * Segment dimensions (days/models/categories/branches/PRs) intersect WITH them * and with each other: within a segment each dimension contributes its share of - * the segment cost — day/category/branch are per-segment values (0 or 1), model - * and PR are fractions (selected-model cost / segment cost, |PR-set ∩ selected| / - * |PR-set|). Multiplying shares keeps every dimension reconcilable: summing a - * single dimension's contributions over the full population reproduces that - * dimension's aggregate exactly. + * each segment — day/category/branch gate it, models select actual accounting + * totals, and PRs apply their existing even-share attribution. Model prices + * never stand in for request or token counts. */ export function contributeRow(row: SessionDrillRow, filters: InvestigationFilters): IncludedRow | null { // Session-level gates first: they apply to the row as a whole. - if (filters.providers.length > 0 && !filters.providers.includes(row.provider)) return null - if (filters.projects.length > 0 && !filters.projects.some(project => rowMatchesProject(row, project))) return null - if (filters.sessions.length > 0 && !filters.sessions.some(s => s.provider === row.provider && s.sessionId === row.sessionId)) return null + if (!rowMatchesSessionDimensions(row, filters)) return null // Only session-level dimensions are active: the row contributes its totals. if (!segmentDimensionsActive(filters)) { @@ -235,21 +231,29 @@ export function contributeRow(row: SessionDrillRow, filters: InvestigationFilter // Segment dimensions need segments; a row without them is honestly // unattributable (reported in the summary), never silently zero-valued // inside the list. - if (!row.contributions) return null + if (!canAttribute(row, filters)) return null let cost = 0 let calls = 0 let tokens = 0 - for (const segment of row.contributions.segments) { + for (const segment of row.contributions!.segments) { if (!segmentMatchesDimensions(segment, filters)) continue if (filters.branches.length > 0 && !(segment.branch !== null && filters.branches.some(b => b.branch === segment.branch && rowMatchesProject(row, b.project)))) continue let share = 1 + let segmentCost = segment.cost + let segmentCalls = segment.calls + let segmentTokens = segment.inputTokens + segment.outputTokens if (filters.models.length > 0) { - // A zero-cost segment has no honest model split: it cannot claim a share. - if (segment.cost <= EPSILON) continue - let selectedModelCost = 0 - for (const model of filters.models) selectedModelCost += segment.models[model] ?? 0 - share *= selectedModelCost / segment.cost + segmentCost = 0 + segmentCalls = 0 + segmentTokens = 0 + for (const model of new Set(filters.models)) { + const usage = segment.modelUsage![model] + if (!usage) continue + segmentCost += segment.models[model] ?? 0 + segmentCalls += usage.calls + segmentTokens += usage.inputTokens + usage.outputTokens + } } if (filters.prs.length > 0) { if (segment.prs.length === 0) continue @@ -259,14 +263,27 @@ export function contributeRow(row: SessionDrillRow, filters: InvestigationFilter share *= hits / segment.prs.length } if (share <= EPSILON) continue - cost += segment.cost * share - calls += segment.calls * share - tokens += (segment.inputTokens + segment.outputTokens) * share + cost += segmentCost * share + calls += segmentCalls * share + tokens += segmentTokens * share } if (cost <= EPSILON && calls <= EPSILON && tokens <= EPSILON) return null return { row, cost, calls, tokens } } +function rowMatchesSessionDimensions(row: SessionDrillRow, filters: InvestigationFilters): boolean { + return (filters.providers.length === 0 || filters.providers.includes(row.provider)) + && (filters.projects.length === 0 || filters.projects.some(project => rowMatchesProject(row, project))) + && (filters.sessions.length === 0 || filters.sessions.some(s => s.provider === row.provider && s.sessionId === row.sessionId)) +} + +function canAttribute(row: SessionDrillRow, filters: InvestigationFilters): boolean { + if (!row.contributions) return false + // Older reports have model costs only. Exclude them with an explicit + // coverage count instead of inventing counts or mixing partial accounting. + return filters.models.length === 0 || row.contributions.segments.every(segment => segment.modelUsage !== undefined) +} + /** Apply the selection over the FULL population (call this before any * pagination/slicing). */ export function applyInvestigation(rows: SessionDrillRow[], filters: InvestigationFilters): SelectionSummary { @@ -282,7 +299,7 @@ export function applyInvestigation(rows: SessionDrillRow[], filters: Investigati // A row the session-level gates allowed but that cannot attribute to the // active segment dimensions (no contribution segments) — counted so the // UI can disclose it instead of silently dropping spend. - if (segmentDimensionsActive(filters) && !row.contributions) unattributable++ + if (rowMatchesSessionDimensions(row, filters) && segmentDimensionsActive(filters) && !canAttribute(row, filters)) unattributable++ continue } included.push(contribution) diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index e33355bb..c5dcaa35 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -564,7 +564,7 @@ export type SessionRow = { // ————— src/session-contributions.ts (drill-through, `sessions --contributions`) ————— /** One attributed slice of a session's in-range spend. Segments PARTITION the - * session (every turn lands in exactly one), so summing a single dimension + * session (every call lands in exactly one), so summing a single dimension * over all segments reconciles that dimension's aggregate exactly. `prs` is * the turn's ACTIVE PR set carried forward like the by-PR attribution: [] * means unattributed, and a multi-PR set is listed whole (attributing to one @@ -576,6 +576,8 @@ export type ContributionSegment = { /** Short model name -> attributed cost (same key family as modelBreakdown). * Sums to `cost`; the unattributable remainder rides under ''. */ models: Record + /** Per-model request/token counts; absent in old cached reports. */ + modelUsage?: Record prs: string[] /** True when the PR set is the legacy whole-session even split (transcript * expired before per-turn capture); absent otherwise. */ diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index 537e26a2..8e9c8896 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -205,7 +205,7 @@ export function Sessions({ ? codeburn.getSessionsContributions(period, provider, fetchRange) : codeburn.getSessionsContributions(period, provider), [period, provider, fetchRange?.from, fetchRange?.to, refreshToken], - { enabled: ready && investigating, memoKey: reportMemoKey('sessioncontrib', period, provider, fetchRange) }, + { enabled: ready && investigating, memoKey: reportMemoKey('sessioncontrib-v2', period, provider, fetchRange) }, ) const report = investigating ? contributionReport : plainReport diff --git a/docs/drill-through.md b/docs/drill-through.md index 5b3bba56..29466b83 100644 --- a/docs/drill-through.md +++ b/docs/drill-through.md @@ -44,6 +44,13 @@ attributed per turn, the rows carry the by-PR report's `~` even-split marker. Sessions that cannot be attributed to the selection (no per-turn detail survived for them) are excluded and counted in the summary ("N sessions could not be attributed") instead of being silently dropped or zero-valued. +The same disclosure applies to ambiguous session identities and old cached +reports without per-model usage when a model filter is active. + +Days follow each call's timestamp, so a turn crossing midnight contributes to +both days. Model filters use the model's actual calls and tokens, independently +of its cost; free models retain their usage. Supplementary accounting retains +cost and tokens without adding requests. ## Drawer, history, and focus @@ -66,8 +73,8 @@ not be attributed") instead of being silently dropped or zero-valued. The renderer stays a pure view over CLI JSON. The drill-through report is `codeburn sessions --format json --contributions` — the same session rows and the same `--project`/`--exclude`/`--provider` filtering semantics as the plain -report, plus per-session `contribution.segments` (turn-granular slices with -day, category, git branch, model, and active PR set) and canonical identities +report, plus per-session `contributions.segments` (call-day slices with +category, git branch, model costs and usage, and active PR set) and canonical identities (`projectId`, `provider`, `sessionId`). Transcripts never cross the IPC boundary; the report is fetched only while a selection is active, and every chip combination is computed client-side over the full fetched population. diff --git a/src/session-contributions.ts b/src/session-contributions.ts index 21b40b61..7adde7ff 100644 --- a/src/session-contributions.ts +++ b/src/session-contributions.ts @@ -1,18 +1,18 @@ -import { behavioralCallCount } from './behavioral-weight.js' +import { isBehavioralCall } from './behavioral-weight.js' import { dateKey } from './day-aggregator.js' import { spendProjectIdentity } from './spend-flow.js' import type { ProjectSummary, SessionSummary } from './types.js' -import { modelBreakdownKey } from './session-output.js' +import { callBillableOutputTokens, inferSessionProvider, modelBreakdownKey } from './session-output.js' import type { SessionRow } from './sessions-report.js' /// One attributed slice of a session's in-range spend. Segments PARTITION the -/// session: every turn lands in exactly one segment (consecutive turns with the +/// session: every call lands in exactly one segment (consecutive calls with the /// same day/category/branch/PR-set are merged), so summing segment costs over a /// session reproduces the session's row cost, and summing a single dimension's /// contributions over all segments reconciles that dimension's aggregate. /// -/// day — local YYYY-MM-DD of the turn (same bucketing as history.daily); -/// '' when the turn carries no usable timestamp. +/// day — local YYYY-MM-DD of the call (same bucketing as history.daily), +/// falling back to its turn when the call timestamp is unusable. /// category — the turn's task category, or null when unclassified. /// branch — the git branch carried forward across the session's turns /// (same reconstruction as aggregateByBranch); null before the @@ -33,6 +33,9 @@ export type ContributionSegment = { category: string | null branch: string | null models: Record + /// Actual request/token counts for each model, independent of its price. + /// Optional only for compatibility with previously cached payloads. + modelUsage?: Record prs: string[] /// True when this segment's PR set is the legacy whole-session even split /// (transcript expired before per-turn capture). Absent otherwise. @@ -69,27 +72,12 @@ export type SessionDrillRow = SessionRow & { contributions?: SessionContributions } -type WorkableTurn = { - timestamp?: string - gitBranch?: string - prRefs?: string[] - category?: string - assistantCalls: Array<{ - costUSD: number - savingsUSD?: number - model?: string - provider?: string - timestamp?: string - usage: { inputTokens: number; outputTokens: number } - }> -} - const UNKNOWN_MODEL_KEY = '' /// The session's local-day key for a turn, mirroring the day aggregator's /// bucketing order (turn timestamp, then the first call's). '' when neither is /// a real timestamp so an unparseable turn can never masquerade as a date. -function turnDayKey(turn: WorkableTurn, fallback: string): string { +function turnDayKey(turn: SessionSummary['turns'][number], fallback: string): string { const iso = turn.timestamp || turn.assistantCalls[0]?.timestamp || fallback if (!iso) return '' const key = dateKey(iso) @@ -118,55 +106,43 @@ export function buildSessionContributions(session: SessionSummary): SessionContr let sawTurnRefs = false let currentBranch: string | null = null - for (const turn of session.turns as WorkableTurn[]) { + for (const turn of session.turns) { if (turn.prRefs?.length) { currentPrs = turn.prRefs; sawTurnRefs = true } if (turn.gitBranch) currentBranch = turn.gitBranch - let cost = 0 - let savings = 0 - let inputTokens = 0 - let outputTokens = 0 - const models = new Map() - for (const call of turn.assistantCalls) { - cost += call.costUSD - savings += call.savingsUSD ?? 0 - inputTokens += call.usage?.inputTokens ?? 0 - outputTokens += call.usage?.outputTokens ?? 0 - const key = modelKeyFor(call) - models.set(key, (models.get(key) ?? 0) + call.costUSD) - } - const calls = behavioralCallCount(turn.assistantCalls as SessionSummary['turns'][number]['assistantCalls']) - - if (cost === 0 && calls === 0 && savings === 0 && inputTokens === 0 && outputTokens === 0) continue - const prs = currentPrs ?? [] - const day = turnDayKey(turn, session.firstTimestamp) + const turnDay = turnDayKey(turn, session.firstTimestamp) const category = turn.category ?? null - - // Merge into the previous segment when every partition key matches, so a - // long run of same-context turns stays one segment in the payload. - const last = segments.at(-1) - if (last && last.day === day && last.category === category && last.branch === currentBranch && prsKey(last.prs) === prsKey(prs)) { - last.cost += cost - last.calls += calls - last.savingsUSD += savings - last.inputTokens += inputTokens - last.outputTokens += outputTokens - for (const [model, value] of models) last.models[model] = (last.models[model] ?? 0) + value - continue + for (const call of turn.assistantCalls) { + const cost = call.costUSD + const savings = call.savingsUSD ?? 0 + const inputTokens = call.usage.inputTokens + const outputTokens = callBillableOutputTokens(call) + const calls = isBehavioralCall(call) ? 1 : 0 + if (cost === 0 && calls === 0 && savings === 0 && inputTokens === 0 && outputTokens === 0) continue + const day = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDay : dateKey(call.timestamp) + const key = modelKeyFor(call) + let segment = segments.at(-1) + if (!segment || segment.day !== day || segment.category !== category || segment.branch !== currentBranch || prsKey(segment.prs) !== prsKey(prs)) { + segment = { + day, category, branch: currentBranch, prs, + models: Object.create(null), modelUsage: Object.create(null), + cost: 0, calls: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, + } + segments.push(segment) + } + segment.cost += cost + segment.calls += calls + segment.savingsUSD += savings + segment.inputTokens += inputTokens + segment.outputTokens += outputTokens + segment.models[key] = (segment.models[key] ?? 0) + cost + const usage = segment.modelUsage![key] ?? { calls: 0, inputTokens: 0, outputTokens: 0 } + usage.calls += calls + usage.inputTokens += inputTokens + usage.outputTokens += outputTokens + segment.modelUsage![key] = usage } - segments.push({ - day, - category, - branch: currentBranch, - models: Object.fromEntries(models), - prs, - cost, - calls, - savingsUSD: savings, - inputTokens, - outputTokens, - }) } // Legacy fallback, mirroring attributeSessionPrSpend: a session whose @@ -185,20 +161,21 @@ export function buildSessionContributions(session: SessionSummary): SessionContr /// Attach `contributions` (and the identity/lineage integration fields) to the /// default session rows. `rows` must be the exact `aggregateSessions(projects)` -/// output: both walk `projects` in the same order, so the arrays zip -/// row-to-session; the sessionId+project check guards against any future -/// ordering drift, and a mismatch degrades to un-annotated rows rather than -/// wrong attribution. +/// output. Provider, project label and session id locate a candidate; ambiguous +/// identities (including identical labels at different paths) stay unannotated +/// because plain SessionRow does not carry a canonical project path. export function withContributions(rows: SessionRow[], projects: ProjectSummary[]): SessionDrillRow[] { - const drillSessions = new Map() + const drillSessions = new Map() + const keyFor = (provider: string, project: string, sessionId: string) => JSON.stringify([provider, project, sessionId]) for (const project of projects) { const projectId = spendProjectIdentity({ project: project.project, projectPath: project.projectPath }).id for (const session of project.sessions) { - drillSessions.set(`${session.sessionId}\u0000${session.project || project.project}`, { session, projectId }) + const key = keyFor(inferSessionProvider(session), session.project || project.project, session.sessionId) + drillSessions.set(key, drillSessions.has(key) ? null : { session, projectId }) } } return rows.map(row => { - const found = drillSessions.get(`${row.sessionId}\u0000${row.project}`) + const found = drillSessions.get(keyFor(row.provider, row.project, row.sessionId)) const drill: SessionDrillRow = { ...row, projectId: found?.projectId } if (found) { const { session } = found diff --git a/tests/session-contributions.test.ts b/tests/session-contributions.test.ts index 67de05a8..e8fb8f58 100644 --- a/tests/session-contributions.test.ts +++ b/tests/session-contributions.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import { buildSessionContributions, withContributions } from '../src/session-contributions.js' import { aggregateSessions } from '../src/sessions-report.js' import { dateKey } from '../src/day-aggregator.js' +import { aggregateProjectsIntoDays } from '../src/day-aggregator.js' import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js' import { CLEARED, REDIRECTED } from './setup/env-isolation-vars.js' @@ -95,6 +96,36 @@ const DAY1 = '2026-09-10T10:00:00.000Z' const DAY2 = '2026-09-11T10:00:00.000Z' describe('session contribution segments', () => { + it('reconciles a single midnight-straddling turn with daily call accounting', () => { + const before = new Date(2026, 8, 10, 23, 59).toISOString() + const after = new Date(2026, 8, 11, 0, 1).toISOString() + const session = sessionWith([turn({ timestamp: before, assistantCalls: [ + call({ timestamp: before, costUSD: 3 }), + call({ timestamp: after, costUSD: 5 }), + ] })]) + const segments = buildSessionContributions(session).segments + const daily = aggregateProjectsIntoDays([projectOf(session)]) + expect(segments.map(s => [s.day, s.cost, s.calls])).toEqual([ + ['2026-09-10', 3, 1], ['2026-09-11', 5, 1], + ]) + expect(segments.map(s => [s.day, s.cost])).toEqual(daily.map(d => [d.date, d.cost])) + }) + + it('retains per-model counts independently of price and supplementary accounting', () => { + const session = sessionWith([turn({ timestamp: DAY1, assistantCalls: [ + call({ timestamp: DAY1, model: 'claude-opus-4-6', costUSD: 9, usage: usage(100, 0) }), + call({ timestamp: DAY1, model: 'claude-sonnet-4-5', costUSD: 1, usage: usage(900, 0) }), + call({ timestamp: DAY1, model: 'claude-opus-4-6', costUSD: 2, usage: usage(50, 0), supplementaryAccounting: true }), + call({ timestamp: DAY1, model: 'free-model', costUSD: 0, usage: usage(75, 0) }), + ] })]) + const segment = buildSessionContributions(session).segments[0]! + expect(segment.modelUsage!['Opus 4.6']).toEqual({ calls: 1, inputTokens: 150, outputTokens: 0 }) + expect(segment.modelUsage!['Sonnet 4.5']).toEqual({ calls: 1, inputTokens: 900, outputTokens: 0 }) + expect(Object.values(segment.modelUsage!).reduce((sum, value) => sum + value.calls, 0)).toBe(3) + expect(Object.values(segment.modelUsage!).reduce((sum, value) => sum + value.inputTokens, 0)).toBe(1125) + expect(segment.models['Opus 4.6']).toBe(11) + }) + it('partition a session across days: sum(segments.cost) == session cost', () => { const session = sessionWith([ turn({ timestamp: DAY1, category: 'coding', assistantCalls: [call({ costUSD: 0.4, timestamp: DAY1 })] }), @@ -245,6 +276,25 @@ describe('session contribution segments', () => { }) describe('withContributions', () => { + it('keeps same-id sessions in different providers separate regardless of row order', () => { + const claude = sessionWith([turn({ timestamp: DAY1, assistantCalls: [call({ timestamp: DAY1, costUSD: 2, provider: 'claude' })] })]) + const codex = sessionWith([turn({ timestamp: DAY1, assistantCalls: [call({ timestamp: DAY1, costUSD: 9, provider: 'codex' })] })]) + const projects = [{ ...projectOf(claude), sessions: [claude, codex] }] + const result = withContributions(aggregateSessions(projects).reverse(), projects) + expect(result.map(r => [r.provider, r.cost, r.contributions!.segments[0]!.cost])).toEqual([ + ['codex', 9, 9], ['claude', 2, 2], + ]) + }) + + it('leaves ambiguous same-provider identities unannotated instead of overwriting', () => { + const first = sessionWith([turn({ timestamp: DAY1, assistantCalls: [call({ timestamp: DAY1, costUSD: 2 })] })]) + const second = sessionWith([turn({ timestamp: DAY1, assistantCalls: [call({ timestamp: DAY1, costUSD: 9 })] })]) + const projects = [projectOf(first), { ...projectOf(second), projectPath: '/different/path' }] + const result = withContributions(aggregateSessions(projects), projects) + expect(result.map(r => r.cost)).toEqual([2, 9]) + expect(result.every(r => r.contributions === undefined)).toBe(true) + }) + it('zip rows to sessions in aggregateSessions order and attach lineage fields', () => { const session = sessionWith( [turn({ timestamp: DAY1, assistantCalls: [call({ costUSD: 0.5, timestamp: DAY1 })] })],