Skip to content

Commit fdad591

Browse files
authored
Merge pull request #132 from github/asizikov/report-adapter-layer
Add usage report adapter layer
2 parents 4f5afd0 + eddab69 commit fdad591

6 files changed

Lines changed: 319 additions & 11 deletions

File tree

src/pipeline/parser.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ describe('validateHeader', () => {
693693
})
694694

695695
it('throws UnsupportedReportVersionError when only aic columns are missing', () => {
696-
const legacyHeader = [
696+
const preAicHeader = [
697697
'date',
698698
'username',
699699
'product',
@@ -710,12 +710,12 @@ describe('validateHeader', () => {
710710
'organization',
711711
'cost_center_name',
712712
].join(',')
713-
const header = parseTokenUsageHeader(legacyHeader)
713+
const header = parseTokenUsageHeader(preAicHeader)
714714
expect(() => validateHeader(header)).toThrow(UnsupportedReportVersionError)
715715
})
716716

717717
it('throws UnsupportedReportVersionError when only one aic column is missing', () => {
718-
const legacyHeader = [
718+
const preAicHeader = [
719719
'date',
720720
'username',
721721
'product',
@@ -733,7 +733,7 @@ describe('validateHeader', () => {
733733
'cost_center_name',
734734
'aic_quantity',
735735
].join(',')
736-
const header = parseTokenUsageHeader(legacyHeader)
736+
const header = parseTokenUsageHeader(preAicHeader)
737737
expect(() => validateHeader(header)).toThrow(UnsupportedReportVersionError)
738738
})
739739

src/pipeline/parser.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,15 @@ export function validateHeader(header: TokenUsageHeader): void {
161161
}
162162
}
163163

164-
export function validateSupportedReportRecord(header: TokenUsageHeader, record: TokenUsageRecord): void {
164+
export function hasNativeAiCreditsReportSignature(header: TokenUsageHeader, record: TokenUsageRecord): boolean {
165165
const lacksExceedsQuota = !('exceeds_quota' in header.index)
166166
const usesNativeAiCreditsUnit = record.unit_type === 'ai-credits' && record.sku.endsWith('_ai_credit')
167167

168-
if (lacksExceedsQuota && usesNativeAiCreditsUnit) {
168+
return lacksExceedsQuota && usesNativeAiCreditsUnit
169+
}
170+
171+
export function validateSupportedReportRecord(header: TokenUsageHeader, record: TokenUsageRecord): void {
172+
if (hasNativeAiCreditsReportSignature(header, record)) {
169173
throw new UnsupportedNativeAiCreditsReportError()
170174
}
171175
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
InvalidReportError,
5+
UnsupportedNativeAiCreditsReportError,
6+
UnsupportedReportVersionError,
7+
parseTokenUsageHeader,
8+
parseTokenUsageRecord,
9+
} from './parser'
10+
import {
11+
detectReportFormat,
12+
selectUsageReportAdapter,
13+
validateUsageReportFirstRecord,
14+
validateUsageReportHeader,
15+
} from './reportAdapters'
16+
17+
const TRANSITION_PERIOD_HEADER = [
18+
'date',
19+
'username',
20+
'product',
21+
'sku',
22+
'model',
23+
'quantity',
24+
'unit_type',
25+
'applied_cost_per_quantity',
26+
'gross_amount',
27+
'discount_amount',
28+
'net_amount',
29+
'exceeds_quota',
30+
'total_monthly_quota',
31+
'organization',
32+
'cost_center_name',
33+
'aic_quantity',
34+
'aic_gross_amount',
35+
].join(',')
36+
37+
const HEADER_WITHOUT_EXCEEDS_QUOTA = [
38+
'date',
39+
'username',
40+
'product',
41+
'sku',
42+
'model',
43+
'quantity',
44+
'unit_type',
45+
'applied_cost_per_quantity',
46+
'gross_amount',
47+
'discount_amount',
48+
'net_amount',
49+
'total_monthly_quota',
50+
'organization',
51+
'cost_center_name',
52+
'aic_quantity',
53+
'aic_gross_amount',
54+
].join(',')
55+
56+
function buildRow(values: string[]): string {
57+
return values.join(',')
58+
}
59+
60+
describe('usage report adapters', () => {
61+
it('detects and selects the Transition Period Billing Preview adapter for current preview reports', () => {
62+
const header = parseTokenUsageHeader(TRANSITION_PERIOD_HEADER)
63+
const record = parseTokenUsageRecord(
64+
buildRow([
65+
'2026-05-29',
66+
'mona',
67+
'copilot',
68+
'copilot_premium_request',
69+
'Auto: Claude Haiku 4.5',
70+
'2',
71+
'requests',
72+
'0.04',
73+
'0.08',
74+
'0',
75+
'0.08',
76+
'False',
77+
'300',
78+
'example-org',
79+
'Cost Center A',
80+
'20',
81+
'0.20',
82+
]),
83+
header,
84+
)
85+
86+
expect(detectReportFormat(header, record)).toBe('transition-period-billing-preview')
87+
expect(selectUsageReportAdapter(header, record).metadata).toMatchObject({
88+
format: 'transition-period-billing-preview',
89+
supported: true,
90+
})
91+
expect(() => validateUsageReportFirstRecord(header, record)).not.toThrow()
92+
})
93+
94+
it('keeps missing-exceeds premium request rows on the Transition Period Billing Preview adapter', () => {
95+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
96+
const record = parseTokenUsageRecord(
97+
buildRow([
98+
'2026-05-29',
99+
'mona',
100+
'copilot',
101+
'copilot_premium_request',
102+
'Auto: Claude Haiku 4.5',
103+
'2',
104+
'requests',
105+
'0.04',
106+
'0.08',
107+
'0',
108+
'0.08',
109+
'300',
110+
'example-org',
111+
'Cost Center A',
112+
'20',
113+
'0.20',
114+
]),
115+
header,
116+
)
117+
118+
expect(detectReportFormat(header, record)).toBe('transition-period-billing-preview')
119+
expect(selectUsageReportAdapter(header, record).metadata.format).toBe('transition-period-billing-preview')
120+
expect(() => validateUsageReportFirstRecord(header, record)).not.toThrow()
121+
})
122+
123+
it('detects native AI Credits reports and routes them to an unsupported adapter', () => {
124+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
125+
const record = parseTokenUsageRecord(
126+
buildRow([
127+
'2026-06-01',
128+
'mona',
129+
'copilot',
130+
'copilot_ai_credit',
131+
'Auto: Claude Haiku 4.5',
132+
'96.9990345',
133+
'ai-credits',
134+
'0.01',
135+
'0.969990345',
136+
'0',
137+
'0.969990345',
138+
'3900',
139+
'example-org',
140+
'',
141+
'96.9990345',
142+
'0.969990345',
143+
]),
144+
header,
145+
)
146+
147+
expect(detectReportFormat(header, record)).toBe('native-ai-credits')
148+
expect(selectUsageReportAdapter(header, record).metadata).toMatchObject({
149+
format: 'native-ai-credits',
150+
supported: false,
151+
})
152+
expect(() => validateUsageReportFirstRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError)
153+
})
154+
155+
it('fails clearly for malformed billing headers before adapter selection', () => {
156+
const header = parseTokenUsageHeader('foo,bar,baz')
157+
158+
expect(() => validateUsageReportHeader(header)).toThrow(InvalidReportError)
159+
})
160+
161+
it('fails clearly for pre-AIC report headers before adapter selection', () => {
162+
const header = parseTokenUsageHeader([
163+
'date',
164+
'username',
165+
'product',
166+
'sku',
167+
'model',
168+
'quantity',
169+
'unit_type',
170+
'applied_cost_per_quantity',
171+
'gross_amount',
172+
'discount_amount',
173+
'net_amount',
174+
'exceeds_quota',
175+
'total_monthly_quota',
176+
'organization',
177+
'cost_center_name',
178+
].join(','))
179+
180+
expect(() => validateUsageReportHeader(header)).toThrow(UnsupportedReportVersionError)
181+
})
182+
})

src/pipeline/reportAdapters.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {
2+
UnsupportedNativeAiCreditsReportError,
3+
hasNativeAiCreditsReportSignature,
4+
validateHeader as validateTokenUsageHeader,
5+
validateSupportedReportRecord,
6+
type TokenUsageHeader,
7+
type TokenUsageRecord,
8+
} from './parser'
9+
10+
export type ReportFormat = 'transition-period-billing-preview' | 'native-ai-credits'
11+
12+
export type ReportFormatMetadata = {
13+
format: ReportFormat
14+
label: string
15+
supported: boolean
16+
}
17+
18+
export interface UsageReportAdapter {
19+
metadata: ReportFormatMetadata
20+
validateHeader(header: TokenUsageHeader): void
21+
validateFirstRecord(header: TokenUsageHeader, record: TokenUsageRecord): void
22+
}
23+
24+
const TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER: UsageReportAdapter = {
25+
metadata: {
26+
format: 'transition-period-billing-preview',
27+
label: 'Transition Period Billing Preview report',
28+
supported: true,
29+
},
30+
validateHeader(header) {
31+
validateTokenUsageHeader(header)
32+
},
33+
validateFirstRecord(header, record) {
34+
validateSupportedReportRecord(header, record)
35+
},
36+
}
37+
38+
const NATIVE_AI_CREDITS_REPORT_ADAPTER: UsageReportAdapter = {
39+
metadata: {
40+
format: 'native-ai-credits',
41+
label: 'Native AI Credits report',
42+
supported: false,
43+
},
44+
validateHeader(header) {
45+
validateTokenUsageHeader(header)
46+
},
47+
validateFirstRecord() {
48+
throw new UnsupportedNativeAiCreditsReportError()
49+
},
50+
}
51+
52+
const REPORT_ADAPTERS: Record<ReportFormat, UsageReportAdapter> = {
53+
'transition-period-billing-preview': TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER,
54+
'native-ai-credits': NATIVE_AI_CREDITS_REPORT_ADAPTER,
55+
}
56+
57+
export function validateUsageReportHeader(header: TokenUsageHeader): void {
58+
validateTokenUsageHeader(header)
59+
}
60+
61+
export function detectReportFormat(header: TokenUsageHeader, firstRecord: TokenUsageRecord): ReportFormat {
62+
// This intentionally mirrors the existing preflight check: format detection only samples the first data row.
63+
if (hasNativeAiCreditsReportSignature(header, firstRecord)) {
64+
return 'native-ai-credits'
65+
}
66+
67+
return 'transition-period-billing-preview'
68+
}
69+
70+
export function selectUsageReportAdapter(header: TokenUsageHeader, firstRecord: TokenUsageRecord): UsageReportAdapter {
71+
return REPORT_ADAPTERS[detectReportFormat(header, firstRecord)]
72+
}
73+
74+
export function validateUsageReportFirstRecord(
75+
header: TokenUsageHeader,
76+
firstRecord: TokenUsageRecord,
77+
): UsageReportAdapter {
78+
const adapter = selectUsageReportAdapter(header, firstRecord)
79+
adapter.validateHeader(header)
80+
adapter.validateFirstRecord(header, firstRecord)
81+
return adapter
82+
}

src/pipeline/runPipeline.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest'
22

33
import type { Aggregator } from './aggregators/base'
4-
import type { TokenUsageHeader, TokenUsageRecord } from './parser'
4+
import { InvalidReportError, UnsupportedReportVersionError, type TokenUsageHeader, type TokenUsageRecord } from './parser'
55
import { runPipeline } from './runPipeline'
66

77
const HEADER = [
@@ -65,6 +65,47 @@ class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord
6565
}
6666

6767
describe('runPipeline', () => {
68+
it('accepts a valid header-only report', async () => {
69+
const aggregator = new CaptureAggregator()
70+
71+
await expect(runPipeline(createCsv([]), [aggregator])).resolves.toEqual({
72+
reportRowCount: 0,
73+
processedRowCount: 0,
74+
})
75+
expect(aggregator.result()).toEqual([])
76+
})
77+
78+
it('rejects a malformed header-only report', async () => {
79+
const aggregator = new CaptureAggregator()
80+
81+
await expect(runPipeline(createCsv([], 'foo,bar,baz'), [aggregator])).rejects.toThrow(InvalidReportError)
82+
expect(aggregator.result()).toEqual([])
83+
})
84+
85+
it('rejects a pre-AIC header-only report', async () => {
86+
const header = [
87+
'date',
88+
'username',
89+
'product',
90+
'sku',
91+
'model',
92+
'quantity',
93+
'unit_type',
94+
'applied_cost_per_quantity',
95+
'gross_amount',
96+
'discount_amount',
97+
'net_amount',
98+
'exceeds_quota',
99+
'total_monthly_quota',
100+
'organization',
101+
'cost_center_name',
102+
].join(',')
103+
const aggregator = new CaptureAggregator()
104+
105+
await expect(runPipeline(createCsv([], header), [aggregator])).rejects.toThrow(UnsupportedReportVersionError)
106+
expect(aggregator.result()).toEqual([])
107+
})
108+
68109
it('rejects native AI Credits reports before processing rows', async () => {
69110
const file = createCsv([
70111
[

0 commit comments

Comments
 (0)