diff --git a/src/models-report.ts b/src/models-report.ts index 0b612d98..b784e15a 100644 --- a/src/models-report.ts +++ b/src/models-report.ts @@ -167,6 +167,10 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat const foldedCategoryCost = new Map>() const foldedTotalCost = new Map() const foldedRawSeen = new Set() + // Empty string on the row means both "none seen" and "conflict". Track + // distinct non-empty baselines separately so a later bucket cannot + // repopulate a conflict that was already detected. + const baselinesByKey = new Map>() for (const bucket of buckets.values()) { const meta = await resolveProvider(bucket.provider) @@ -184,6 +188,11 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat }) : null + const baselines = baselinesByKey.get(resolvedKey) ?? new Set() + if (bucket.savingsBaselineModel) baselines.add(bucket.savingsBaselineModel) + baselinesByKey.set(resolvedKey, baselines) + const resolvedBaseline = baselines.size === 1 ? [...baselines][0]! : '' + const existing = rowsByKey.get(resolvedKey) if (existing) { existing.inputTokens += bucket.inputTokens @@ -195,12 +204,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat existing.savingsUSD += bucket.savingsUSD existing.calls += bucket.calls if (bucket.model < existing.model) existing.model = bucket.model - if (existing.savingsBaselineModel && bucket.savingsBaselineModel - && existing.savingsBaselineModel !== bucket.savingsBaselineModel) { - existing.savingsBaselineModel = '' - } else if (!existing.savingsBaselineModel && bucket.savingsBaselineModel) { - existing.savingsBaselineModel = bucket.savingsBaselineModel - } + existing.savingsBaselineModel = resolvedBaseline if (existing.credits === null || bucketCredits === null) existing.credits = null else existing.credits += bucketCredits } else { @@ -218,7 +222,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat totalTokens: total, costUSD: bucket.costUSD, savingsUSD: bucket.savingsUSD, - savingsBaselineModel: bucket.savingsBaselineModel, + savingsBaselineModel: resolvedBaseline, calls: bucket.calls, credits: bucketCredits, }) diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 44b22afa..5c933eac 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -37,6 +37,8 @@ function makeCall(opts: { output?: number cacheWrite?: number cacheRead?: number + savingsUSD?: number + savingsBaselineModel?: string }): ParsedApiCall { return { provider: opts.provider, @@ -58,6 +60,8 @@ function makeCall(opts: { timestamp: '2026-05-09T00:00:00.000Z', bashCommands: [], deduplicationKey: `${opts.provider}-${opts.model}-${opts.costUSD}`, + savingsUSD: opts.savingsUSD, + savingsBaselineModel: opts.savingsBaselineModel, } } @@ -228,6 +232,29 @@ describe('aggregateModels', () => { expect(rows[0]!.topCategoryShare).toBeCloseTo(0.246 / 0.265, 3) }) + it('clears a merged savings baseline when three raw ids disagree', async () => { + const rows = await aggregateModels([makeProject([ + makeTurn('feature', [makeCall({ + provider: 'cline-cli', model: 'glm-5p2', + costUSD: 1, savingsUSD: 2, savingsBaselineModel: 'gpt-4o', + })]), + makeTurn('feature', [makeCall({ + provider: 'cline-cli', model: 'GLM-5.2', + costUSD: 1, savingsUSD: 2, savingsBaselineModel: 'claude-sonnet-4-6', + })]), + makeTurn('feature', [makeCall({ + provider: 'cline-cli', model: 'accounts/fireworks/models/glm-5p2', + costUSD: 1, savingsUSD: 2, savingsBaselineModel: 'gpt-5', + })]), + ])]) + expect(rows).toHaveLength(1) + expect(rows[0]!.provider).toBe('cline-cli') + expect(rows[0]!.modelDisplayName).toBe('GLM-5.2') + expect(rows[0]!.savingsUSD).toBe(6) + expect(rows[0]!.savingsBaselineModel).toBe('') + expect(rows[0]!.calls).toBe(3) + }) + it('does not merge the same display name across providers', async () => { const rows = await aggregateModels([makeProject([ makeTurn('feature', [makeCall({ provider: 'cline-cli', model: 'glm-5p2', costUSD: 1 })]),