Skip to content

Commit 0a33b96

Browse files
authored
Merge pull request #138 from github/asizikov/aggregator-canonical-metrics
Refactor aggregators to use canonical metrics
2 parents dbd86b3 + 8321066 commit 0a33b96

9 files changed

Lines changed: 437 additions & 12 deletions

src/pipeline/aggregators/costCenterAggregator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { Aggregator } from './base'
2-
import { getUsageMetrics, type TokenUsageHeader, type TokenUsageRecord } from '../parser'
2+
import type { TokenUsageHeader, TokenUsageRecord } from '../parser'
33
import { getDisplayModelName } from '../modelLabels'
44
import { isNonCopilotCodeReviewUsage, NON_COPILOT_CODE_REVIEW_USER_LABEL } from '../productClassification'
55
import { pickTopEntries } from './topBreakdown'
6+
import type { ReportFormat, ReportFormatMetadata } from '../reportAdapters'
7+
import { getAggregatorReportFormat, getAggregatorUsageMetrics } from './usageMetrics'
68

79
export type CostTotals = {
810
requests: number
@@ -74,6 +76,11 @@ function ensureUserTotals(map: Map<string, CostCenterUserTotals>, key: string):
7476

7577
export class CostCenterAggregator implements Aggregator<TokenUsageRecord, CostCenterResult, TokenUsageHeader> {
7678
private byCostCenter = new Map<string, CostCenterInternal>()
79+
private readonly reportFormat: ReportFormat
80+
81+
constructor(reportMetadataOrFormat?: ReportFormat | ReportFormatMetadata) {
82+
this.reportFormat = getAggregatorReportFormat(reportMetadataOrFormat)
83+
}
7784

7885
onHeader(): void {
7986
// header is intentionally ignored (we rely on parsed TokenUsageRecord fields)
@@ -102,7 +109,7 @@ export class CostCenterAggregator implements Aggregator<TokenUsageRecord, CostCe
102109

103110
if (username) costCenter.users.add(username)
104111

105-
const { requests, grossAmount, discountAmount, netAmount, aicQuantity, aicGrossAmount, aicNetAmount } = getUsageMetrics(record)
112+
const { requests, grossAmount, discountAmount, netAmount, aicQuantity, aicGrossAmount, aicNetAmount } = getAggregatorUsageMetrics(record, this.reportFormat)
106113

107114
costCenter.totals.requests += requests
108115
costCenter.totals.grossAmount += grossAmount

src/pipeline/aggregators/dailyUsageAggregator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Aggregator } from './base'
2-
import { getUsageMetrics, type TokenUsageRecord } from '../parser'
2+
import type { TokenUsageRecord } from '../parser'
3+
import type { ReportFormat, ReportFormatMetadata } from '../reportAdapters'
4+
import { getAggregatorReportFormat, getAggregatorUsageMetrics } from './usageMetrics'
35

46
export interface DailyUsageData {
57
date: string
@@ -18,14 +20,19 @@ export interface DailyUsageResult {
1820

1921
export class DailyUsageAggregator implements Aggregator<TokenUsageRecord, DailyUsageResult> {
2022
private dataByDate = new Map<string, DailyUsageData>()
23+
private readonly reportFormat: ReportFormat
24+
25+
constructor(reportMetadataOrFormat?: ReportFormat | ReportFormatMetadata) {
26+
this.reportFormat = getAggregatorReportFormat(reportMetadataOrFormat)
27+
}
2128

2229
accumulate(record: TokenUsageRecord): void {
2330
const date = record.date ?? ''
2431
if (!date) return
2532

2633
const existing = this.dataByDate.get(date)
2734

28-
const { requests, aicQuantity, grossAmount, aicGrossAmount, aicNetAmount, discountAmount, netAmount } = getUsageMetrics(record)
35+
const { requests, aicQuantity, grossAmount, aicGrossAmount, aicNetAmount, discountAmount, netAmount } = getAggregatorUsageMetrics(record, this.reportFormat)
2936

3037
if (existing) {
3138
existing.requests += requests

src/pipeline/aggregators/modelUsageAggregator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Aggregator } from './base'
2-
import { getUsageMetrics, type TokenUsageHeader, type TokenUsageRecord } from '../parser'
2+
import type { TokenUsageHeader, TokenUsageRecord } from '../parser'
33
import { getDisplayModelName } from '../modelLabels'
4+
import type { ReportFormat, ReportFormatMetadata } from '../reportAdapters'
5+
import { getAggregatorReportFormat, getAggregatorUsageMetrics } from './usageMetrics'
46

57
export type ModelDailyUsageData = {
68
date: string
@@ -54,6 +56,11 @@ function ensureDay(model: ModelInternal, date: string): ModelDailyUsageData {
5456

5557
export class ModelUsageAggregator implements Aggregator<TokenUsageRecord, ModelUsageResult, TokenUsageHeader> {
5658
private byModel = new Map<string, ModelInternal>()
59+
private readonly reportFormat: ReportFormat
60+
61+
constructor(reportMetadataOrFormat?: ReportFormat | ReportFormatMetadata) {
62+
this.reportFormat = getAggregatorReportFormat(reportMetadataOrFormat)
63+
}
5764

5865
onHeader(): void {
5966
// header is intentionally ignored (we rely on parsed TokenUsageRecord fields)
@@ -82,7 +89,7 @@ export class ModelUsageAggregator implements Aggregator<TokenUsageRecord, ModelU
8289
this.byModel.set(modelName, model)
8390
}
8491

85-
const { requests, aicQuantity, grossAmount, aicGrossAmount, aicNetAmount, discountAmount, netAmount } = getUsageMetrics(record)
92+
const { requests, aicQuantity, grossAmount, aicGrossAmount, aicNetAmount, discountAmount, netAmount } = getAggregatorUsageMetrics(record, this.reportFormat)
8693

8794
const day = ensureDay(model, date)
8895
day.requests += requests
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { parseNativeAiCreditsUsageRecord, parseTokenUsageHeader, type TokenUsageRecord } from '../parser'
4+
import { CostCenterAggregator } from './costCenterAggregator'
5+
import { DailyUsageAggregator } from './dailyUsageAggregator'
6+
import { ModelUsageAggregator } from './modelUsageAggregator'
7+
import { OrganizationAggregator } from './organizationAggregator'
8+
import { ProductUsageAggregator } from './productUsageAggregator'
9+
import { UserUsageAggregator } from './userUsageAggregator'
10+
11+
const NATIVE_AI_CREDITS_HEADER = [
12+
'date',
13+
'username',
14+
'product',
15+
'sku',
16+
'model',
17+
'quantity',
18+
'unit_type',
19+
'applied_cost_per_quantity',
20+
'gross_amount',
21+
'discount_amount',
22+
'net_amount',
23+
'total_monthly_quota',
24+
'organization',
25+
'cost_center_name',
26+
'aic_quantity',
27+
'aic_gross_amount',
28+
].join(',')
29+
30+
const NATIVE_AI_CREDITS_PARSED_HEADER = parseTokenUsageHeader(NATIVE_AI_CREDITS_HEADER)
31+
32+
function nativeRecord(values: string[]): TokenUsageRecord {
33+
return parseNativeAiCreditsUsageRecord(values.join(','), NATIVE_AI_CREDITS_PARSED_HEADER)
34+
}
35+
36+
function nativeRecords(): TokenUsageRecord[] {
37+
return [
38+
nativeRecord([
39+
'5/29/26',
40+
'mona',
41+
'copilot',
42+
'copilot_ai_credit',
43+
'GPT-5.2',
44+
'10',
45+
'ai-credits',
46+
'0.01',
47+
'100',
48+
'20',
49+
'80',
50+
'3900',
51+
'example-org',
52+
'Cost Center A',
53+
'999',
54+
'999',
55+
]),
56+
nativeRecord([
57+
'05/29/2026',
58+
'hubot',
59+
'spark',
60+
'spark_ai_credit',
61+
'GPT-5.2',
62+
'25',
63+
'ai-credits',
64+
'0.01',
65+
'250',
66+
'50',
67+
'200',
68+
'7000',
69+
'octodemo',
70+
'Cost Center B',
71+
'',
72+
'',
73+
]),
74+
nativeRecord([
75+
'2026-05-30',
76+
'octocat',
77+
'copilot',
78+
'coding_agent_ai_credit',
79+
'Copilot Coding Agent: Claude Sonnet 4.6',
80+
'40',
81+
'ai-credits',
82+
'0.01',
83+
'400',
84+
'75',
85+
'325',
86+
'7000',
87+
'example-org',
88+
'Cost Center A',
89+
'4000',
90+
'4000',
91+
]),
92+
]
93+
}
94+
95+
function aggregate(records: TokenUsageRecord[]) {
96+
const daily = new DailyUsageAggregator('native-ai-credits')
97+
const users = new UserUsageAggregator('native-ai-credits')
98+
const organizations = new OrganizationAggregator('native-ai-credits')
99+
const costCenters = new CostCenterAggregator('native-ai-credits')
100+
const models = new ModelUsageAggregator('native-ai-credits')
101+
const products = new ProductUsageAggregator('native-ai-credits')
102+
const aggregators = [daily, users, organizations, costCenters, models, products]
103+
104+
records.forEach((record) => {
105+
aggregators.forEach((aggregator) => aggregator.accumulate(record))
106+
})
107+
108+
return {
109+
daily: daily.result(),
110+
users: users.result(),
111+
organizations: organizations.result(),
112+
costCenters: costCenters.result(),
113+
models: models.result(),
114+
products: products.result(),
115+
}
116+
}
117+
118+
describe('native AI Credits direct aggregator usage', () => {
119+
it('keeps native usage test-only while mapping canonical AIC metrics into existing daily and model fields', () => {
120+
const result = aggregate(nativeRecords())
121+
122+
expect(result.daily.dailyData).toEqual([
123+
expect.objectContaining({
124+
date: '2026-05-29',
125+
requests: 0,
126+
grossAmount: 0,
127+
discountAmount: 0,
128+
netAmount: 0,
129+
aicQuantity: 35,
130+
aicGrossAmount: 350,
131+
aicNetAmount: 280,
132+
}),
133+
expect.objectContaining({
134+
date: '2026-05-30',
135+
requests: 0,
136+
grossAmount: 0,
137+
discountAmount: 0,
138+
netAmount: 0,
139+
aicQuantity: 40,
140+
aicGrossAmount: 400,
141+
aicNetAmount: 325,
142+
}),
143+
])
144+
145+
expect(result.models.totalsByModel['GPT-5.2']).toEqual({
146+
requests: 0,
147+
grossAmount: 0,
148+
discountAmount: 0,
149+
netAmount: 0,
150+
aicQuantity: 35,
151+
aicGrossAmount: 350,
152+
aicNetAmount: 280,
153+
})
154+
expect(result.models.totalsByModel['Copilot Coding Agent: Claude Sonnet 4.6']).toEqual({
155+
requests: 0,
156+
grossAmount: 0,
157+
discountAmount: 0,
158+
netAmount: 0,
159+
aicQuantity: 40,
160+
aicGrossAmount: 400,
161+
aicNetAmount: 325,
162+
})
163+
})
164+
165+
it('maps native actual costs into product, user, organization, and cost-center aic fields', () => {
166+
const result = aggregate(nativeRecords())
167+
168+
const copilot = result.products.products.find((product) => product.product === 'Copilot')
169+
expect(copilot?.totals).toEqual({
170+
requests: 0,
171+
grossAmount: 0,
172+
netAmount: 0,
173+
aicQuantity: 10,
174+
aicGrossAmount: 100,
175+
aicNetAmount: 80,
176+
})
177+
expect(copilot?.models['GPT-5.2']).toEqual({
178+
requests: 0,
179+
grossAmount: 0,
180+
netAmount: 0,
181+
aicQuantity: 10,
182+
aicGrossAmount: 100,
183+
aicNetAmount: 80,
184+
})
185+
186+
const mona = result.users.users.find((user) => user.username === 'mona')
187+
expect(mona?.totals).toEqual(expect.objectContaining({
188+
requests: 0,
189+
grossAmount: 0,
190+
discountAmount: 0,
191+
netAmount: 0,
192+
aicQuantity: 10,
193+
aicGrossAmount: 100,
194+
aicNetAmount: 80,
195+
}))
196+
expect(mona?.daily['2026-05-29']).toEqual(expect.objectContaining({
197+
requests: 0,
198+
grossAmount: 0,
199+
discountAmount: 0,
200+
netAmount: 0,
201+
aicQuantity: 10,
202+
aicGrossAmount: 100,
203+
aicNetAmount: 80,
204+
}))
205+
206+
const exampleOrg = result.organizations.organizations.find((organization) => organization.organization === 'example-org')
207+
expect(exampleOrg?.totals).toEqual({
208+
requests: 0,
209+
grossAmount: 0,
210+
discountAmount: 0,
211+
netAmount: 0,
212+
aicQuantity: 50,
213+
aicGrossAmount: 500,
214+
aicNetAmount: 405,
215+
})
216+
expect(exampleOrg?.totalsByUser.mona).toEqual({
217+
requests: 0,
218+
grossAmount: 0,
219+
netAmount: 0,
220+
aicQuantity: 10,
221+
aicGrossAmount: 100,
222+
aicNetAmount: 80,
223+
})
224+
225+
const costCenter = result.costCenters.costCenters.find((entry) => entry.costCenterName === 'Cost Center A')
226+
expect(costCenter?.totals).toEqual({
227+
requests: 0,
228+
grossAmount: 0,
229+
discountAmount: 0,
230+
netAmount: 0,
231+
aicQuantity: 50,
232+
aicGrossAmount: 500,
233+
aicNetAmount: 405,
234+
})
235+
expect(costCenter?.totalsByUser.octocat).toEqual({
236+
requests: 0,
237+
grossAmount: 0,
238+
netAmount: 0,
239+
aicQuantity: 40,
240+
aicGrossAmount: 400,
241+
aicNetAmount: 325,
242+
})
243+
})
244+
})

src/pipeline/aggregators/organizationAggregator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { Aggregator } from './base'
2-
import { getUsageMetrics, type TokenUsageHeader, type TokenUsageRecord } from '../parser'
2+
import type { TokenUsageHeader, TokenUsageRecord } from '../parser'
33
import { getDisplayModelName } from '../modelLabels'
44
import { isNonCopilotCodeReviewUsage, NON_COPILOT_CODE_REVIEW_USER_LABEL } from '../productClassification'
55
import { pickTopEntries } from './topBreakdown'
6+
import type { ReportFormat, ReportFormatMetadata } from '../reportAdapters'
7+
import { getAggregatorReportFormat, getAggregatorUsageMetrics } from './usageMetrics'
68

79
export type OrgTotals = {
810
requests: number
@@ -65,6 +67,11 @@ function ensureUserTotals(map: Map<string, OrgUserTotals>, key: string): OrgUser
6567

6668
export class OrganizationAggregator implements Aggregator<TokenUsageRecord, OrganizationResult, TokenUsageHeader> {
6769
private byOrg = new Map<string, OrgInternal>()
70+
private readonly reportFormat: ReportFormat
71+
72+
constructor(reportMetadataOrFormat?: ReportFormat | ReportFormatMetadata) {
73+
this.reportFormat = getAggregatorReportFormat(reportMetadataOrFormat)
74+
}
6875

6976
onHeader(): void {
7077
// header is intentionally ignored (we rely on parsed TokenUsageRecord fields)
@@ -93,7 +100,7 @@ export class OrganizationAggregator implements Aggregator<TokenUsageRecord, Orga
93100

94101
if (username) org.users.add(username)
95102

96-
const { requests, grossAmount, discountAmount, netAmount, aicQuantity, aicGrossAmount, aicNetAmount } = getUsageMetrics(record)
103+
const { requests, grossAmount, discountAmount, netAmount, aicQuantity, aicGrossAmount, aicNetAmount } = getAggregatorUsageMetrics(record, this.reportFormat)
97104

98105
org.totals.requests += requests
99106
org.totals.grossAmount += grossAmount

0 commit comments

Comments
 (0)