Fix drill-through provider, day and model attribution

This commit is contained in:
ozymandiashh 2026-09-12 13:25:14 +03:00 • committed by iamtoruk
parent d1dcc82ee5
commit 756af7fcdf
7 changed files with 171 additions and 94 deletions

View file

@ -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'))

View file

@ -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)

View file

@ -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<string, number>
/** Per-model request/token counts; absent in old cached reports. */
modelUsage?: Record<string, { calls: number; inputTokens: number; outputTokens: number }>
prs: string[]
/** True when the PR set is the legacy whole-session even split (transcript
* expired before per-turn capture); absent otherwise. */

View file

@ -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

View file

@ -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.

View file

@ -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<string, number>
/// Actual request/token counts for each model, independent of its price.
/// Optional only for compatibility with previously cached payloads.
modelUsage?: Record<string, { calls: number; inputTokens: number; outputTokens: number }>
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<string, number>()
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<string, { session: SessionSummary; projectId: string }>()
const drillSessions = new Map<string, { session: SessionSummary; projectId: string } | null>()
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

View file

@ -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 })] })],