Skip to content

Commit d67740d

Browse files
authored
Merge pull request #136 from github/asizikov/report-usage-metrics
Add report usage metrics helper
2 parents 7f5a5d6 + 0468121 commit d67740d

2 files changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
parseNativeAiCreditsUsageRecord,
5+
parseTokenUsageHeader,
6+
parseTokenUsageRecord,
7+
type TokenUsageRecord,
8+
} from './parser'
9+
import { getReportUsageMetrics, type CanonicalAiCreditsMetrics } from './reportUsageMetrics'
10+
import type { ReportFormatMetadata } from './reportAdapters'
11+
12+
const TRANSITION_PERIOD_HEADER = [
13+
'date',
14+
'username',
15+
'product',
16+
'sku',
17+
'model',
18+
'quantity',
19+
'unit_type',
20+
'applied_cost_per_quantity',
21+
'gross_amount',
22+
'discount_amount',
23+
'net_amount',
24+
'exceeds_quota',
25+
'total_monthly_quota',
26+
'organization',
27+
'cost_center_name',
28+
'aic_quantity',
29+
'aic_gross_amount',
30+
].join(',')
31+
32+
const NATIVE_AI_CREDITS_HEADER = [
33+
'date',
34+
'username',
35+
'product',
36+
'sku',
37+
'model',
38+
'quantity',
39+
'unit_type',
40+
'applied_cost_per_quantity',
41+
'gross_amount',
42+
'discount_amount',
43+
'net_amount',
44+
'total_monthly_quota',
45+
'organization',
46+
'cost_center_name',
47+
'aic_quantity',
48+
'aic_gross_amount',
49+
].join(',')
50+
51+
const TRANSITION_PERIOD_METADATA: ReportFormatMetadata = {
52+
format: 'transition-period-billing-preview',
53+
label: 'Transition Period Billing Preview report',
54+
supported: true,
55+
}
56+
57+
function buildRow(values: string[]): string {
58+
return values.join(',')
59+
}
60+
61+
function sumAiCredits(metrics: CanonicalAiCreditsMetrics[]): CanonicalAiCreditsMetrics {
62+
return metrics.reduce<CanonicalAiCreditsMetrics>((total, metric) => ({
63+
quantity: total.quantity + metric.quantity,
64+
grossAmount: total.grossAmount + metric.grossAmount,
65+
discountAmount: total.discountAmount + metric.discountAmount,
66+
netAmount: total.netAmount + metric.netAmount,
67+
}), {
68+
quantity: 0,
69+
grossAmount: 0,
70+
discountAmount: 0,
71+
netAmount: 0,
72+
})
73+
}
74+
75+
describe('getReportUsageMetrics', () => {
76+
it('preserves transition-period PRU comparison and AIC metrics for request rows', () => {
77+
const header = parseTokenUsageHeader(TRANSITION_PERIOD_HEADER)
78+
const record = parseTokenUsageRecord(
79+
buildRow([
80+
'2026-05-29',
81+
'mona',
82+
'copilot',
83+
'copilot_premium_request',
84+
'Auto: Claude Haiku 4.5',
85+
'2.5',
86+
'requests',
87+
'0.04',
88+
'0.10',
89+
'0.03',
90+
'0.07',
91+
'False',
92+
'300',
93+
'example-org',
94+
'Cost Center A',
95+
'1.5',
96+
'0.015',
97+
]),
98+
header,
99+
)
100+
101+
expect(getReportUsageMetrics(record, TRANSITION_PERIOD_METADATA)).toEqual({
102+
aiCredits: {
103+
quantity: 1.5,
104+
grossAmount: 0.015,
105+
discountAmount: 0,
106+
netAmount: 0.015,
107+
},
108+
transitionPeriodComparison: {
109+
requests: 2.5,
110+
grossAmount: 0.1,
111+
discountAmount: 0.03,
112+
netAmount: 0.07,
113+
},
114+
})
115+
})
116+
117+
it('preserves transition-period AI Credits row semantics and PRU comparison zeros', () => {
118+
const header = parseTokenUsageHeader(TRANSITION_PERIOD_HEADER)
119+
const record = parseTokenUsageRecord(
120+
buildRow([
121+
'2026-05-29',
122+
'mona',
123+
'copilot',
124+
'copilot_ai_credit',
125+
'Auto: Claude Haiku 4.5',
126+
'50',
127+
'ai-credits',
128+
'0.01',
129+
'0.50',
130+
'0.10',
131+
'0.40',
132+
'False',
133+
'300',
134+
'example-org',
135+
'Cost Center A',
136+
'',
137+
'',
138+
]),
139+
header,
140+
)
141+
142+
expect(getReportUsageMetrics(record, 'transition-period-billing-preview')).toEqual({
143+
aiCredits: {
144+
quantity: 50,
145+
grossAmount: 0.5,
146+
discountAmount: 0,
147+
netAmount: 0.5,
148+
},
149+
transitionPeriodComparison: {
150+
requests: 0,
151+
grossAmount: 0,
152+
discountAmount: 0,
153+
netAmount: 0,
154+
},
155+
})
156+
})
157+
158+
it('uses native AI Credits quantity and cost fields as actual AIC metrics with no PRU comparison', () => {
159+
const header = parseTokenUsageHeader(NATIVE_AI_CREDITS_HEADER)
160+
const records = [
161+
parseNativeAiCreditsUsageRecord(
162+
buildRow([
163+
'5/29/26',
164+
'hubot',
165+
'spark',
166+
'spark_ai_credit',
167+
'GPT-5.2',
168+
'12.5',
169+
'ai-credits',
170+
'0.01',
171+
'0.125',
172+
'0.025',
173+
'0.1',
174+
'7000',
175+
'octodemo',
176+
'',
177+
'',
178+
'',
179+
]),
180+
header,
181+
),
182+
parseNativeAiCreditsUsageRecord(
183+
buildRow([
184+
'5/30/26',
185+
'octocat',
186+
'copilot',
187+
'copilot_ai_credit',
188+
'GPT-5.2',
189+
'50',
190+
'ai-credits',
191+
'0.01',
192+
'0.50',
193+
'0.20',
194+
'0.30',
195+
'3900',
196+
'example-org',
197+
'Cost Center A',
198+
'75',
199+
'0.75',
200+
]),
201+
header,
202+
),
203+
]
204+
const metrics = records.map((record) => getReportUsageMetrics(record, 'native-ai-credits'))
205+
206+
expect(metrics.every((metric) => metric.transitionPeriodComparison === null)).toBe(true)
207+
expect(sumAiCredits(metrics.map((metric) => metric.aiCredits))).toEqual({
208+
quantity: 62.5,
209+
grossAmount: 0.625,
210+
discountAmount: 0.225,
211+
netAmount: 0.4,
212+
})
213+
})
214+
215+
it('matches native parsing helper alias fallback behavior when alias columns are blank', () => {
216+
const header = parseTokenUsageHeader(NATIVE_AI_CREDITS_HEADER)
217+
const row = buildRow([
218+
'5/29/26',
219+
'hubot',
220+
'spark',
221+
'spark_ai_credit',
222+
'GPT-5.2',
223+
'12.5',
224+
'ai-credits',
225+
'0.01',
226+
'0.125',
227+
'0.025',
228+
'0.1',
229+
'7000',
230+
'octodemo',
231+
'',
232+
'',
233+
'',
234+
])
235+
const rawRecord = parseTokenUsageRecord(row, header)
236+
const nativeRecord = parseNativeAiCreditsUsageRecord(row, header)
237+
238+
expect(getReportUsageMetrics(rawRecord, 'native-ai-credits')).toMatchObject({
239+
aiCredits: getReportUsageMetrics(nativeRecord, 'native-ai-credits').aiCredits,
240+
transitionPeriodComparison: null,
241+
})
242+
})
243+
244+
it('derives transition-period AIC discount from current AIC gross and net semantics', () => {
245+
const record: TokenUsageRecord = {
246+
date: '2026-05-29',
247+
username: 'mona',
248+
product: 'copilot',
249+
sku: 'copilot_premium_request',
250+
model: 'GPT-5.2',
251+
quantity: 2,
252+
unit_type: 'requests',
253+
applied_cost_per_quantity: 0.04,
254+
gross_amount: 0.08,
255+
discount_amount: 0,
256+
net_amount: 0.08,
257+
exceeds_quota: false,
258+
total_monthly_quota: 300,
259+
organization: 'example-org',
260+
cost_center_name: 'Cost Center A',
261+
aic_quantity: 8,
262+
aic_gross_amount: 0.08,
263+
aic_net_amount: 0.03,
264+
has_aic_quantity: true,
265+
has_aic_gross_amount: true,
266+
}
267+
268+
expect(getReportUsageMetrics(record, 'transition-period-billing-preview').aiCredits).toEqual({
269+
quantity: 8,
270+
grossAmount: 0.08,
271+
discountAmount: 0.05,
272+
netAmount: 0.03,
273+
})
274+
})
275+
})

src/pipeline/reportUsageMetrics.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { getUsageMetrics, type TokenUsageRecord } from './parser'
2+
import type { ReportFormat, ReportFormatMetadata } from './reportAdapters'
3+
4+
export type CanonicalAiCreditsMetrics = {
5+
quantity: number
6+
grossAmount: number
7+
discountAmount: number
8+
netAmount: number
9+
}
10+
11+
export type TransitionPeriodComparisonMetrics = {
12+
requests: number
13+
grossAmount: number
14+
discountAmount: number
15+
netAmount: number
16+
}
17+
18+
export type ReportUsageMetrics = {
19+
aiCredits: CanonicalAiCreditsMetrics
20+
transitionPeriodComparison: TransitionPeriodComparisonMetrics | null
21+
}
22+
23+
function getReportFormat(reportMetadataOrFormat: ReportFormat | ReportFormatMetadata): ReportFormat {
24+
return typeof reportMetadataOrFormat === 'string'
25+
? reportMetadataOrFormat
26+
: reportMetadataOrFormat.format
27+
}
28+
29+
function getTransitionPeriodReportUsageMetrics(record: TokenUsageRecord): ReportUsageMetrics {
30+
const metrics = getUsageMetrics(record)
31+
32+
return {
33+
aiCredits: {
34+
quantity: metrics.aicQuantity,
35+
grossAmount: metrics.aicGrossAmount,
36+
discountAmount: metrics.aicGrossAmount - metrics.aicNetAmount,
37+
netAmount: metrics.aicNetAmount,
38+
},
39+
transitionPeriodComparison: {
40+
requests: metrics.requests,
41+
grossAmount: metrics.grossAmount,
42+
discountAmount: metrics.discountAmount,
43+
netAmount: metrics.netAmount,
44+
},
45+
}
46+
}
47+
48+
function getNativeAiCreditsReportUsageMetrics(record: TokenUsageRecord): ReportUsageMetrics {
49+
return {
50+
aiCredits: {
51+
quantity: record.quantity,
52+
grossAmount: record.gross_amount,
53+
discountAmount: record.discount_amount,
54+
netAmount: record.net_amount,
55+
},
56+
transitionPeriodComparison: null,
57+
}
58+
}
59+
60+
export function getReportUsageMetrics(
61+
record: TokenUsageRecord,
62+
reportMetadataOrFormat: ReportFormat | ReportFormatMetadata,
63+
): ReportUsageMetrics {
64+
if (getReportFormat(reportMetadataOrFormat) === 'native-ai-credits') {
65+
return getNativeAiCreditsReportUsageMetrics(record)
66+
}
67+
68+
return getTransitionPeriodReportUsageMetrics(record)
69+
}

0 commit comments

Comments
 (0)