mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 22:29:39 +00:00
Fix drill-through provider, day and model attribution
This commit is contained in:
parent
d1dcc82ee5
commit
756af7fcdf
7 changed files with 171 additions and 94 deletions
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })] })],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue