Skip to content

Commit 26fc1ef

Browse files
authored
Merge pull request #139 from github/asizikov/adapter-owned-parsing
Move report row parsing into adapters
2 parents 0a33b96 + 82b210f commit 26fc1ef

3 files changed

Lines changed: 130 additions & 22 deletions

File tree

src/pipeline/reportAdapters.test.ts

Lines changed: 115 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -120,36 +120,135 @@ describe('usage report adapters', () => {
120120
expect(() => validateUsageReportFirstRecord(header, record)).not.toThrow()
121121
})
122122

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(
123+
it('normalizes transition-period rows through the adapter parser', () => {
124+
const header = parseTokenUsageHeader(TRANSITION_PERIOD_HEADER)
125+
const adapter = validateUsageReportHeader(header)
126+
127+
expect(adapter.parseRecord(
126128
buildRow([
127-
'2026-06-01',
129+
'2026-04-25',
128130
'mona',
129131
'copilot',
130-
'copilot_ai_credit',
131-
'Auto: Claude Haiku 4.5',
132-
'96.9990345',
133-
'ai-credits',
134-
'0.01',
135-
'0.969990345',
132+
'copilot_premium_request',
133+
'GPT-5',
136134
'0',
137-
'0.969990345',
138-
'3900',
139-
'example-org',
135+
'requests',
136+
'0.04',
137+
'0',
138+
'0',
139+
'0',
140+
'False',
141+
'300',
142+
'',
143+
'',
144+
'0',
145+
'0',
146+
]),
147+
header,
148+
)).toBeNull()
149+
150+
expect(adapter.parseRecord(
151+
buildRow([
152+
'2026-04-25',
153+
'mona',
154+
'copilot',
155+
'copilot_premium_request',
156+
'GPT-5',
157+
'10',
158+
'requests',
159+
'0.04',
160+
'0.40',
161+
'0',
162+
'0.40',
163+
'False',
164+
'0',
165+
'',
140166
'',
141-
'96.9990345',
142-
'0.969990345',
167+
'100',
168+
'1.00',
143169
]),
144170
header,
171+
)).toMatchObject({
172+
username: 'mona',
173+
quantity: 0,
174+
gross_amount: 0,
175+
net_amount: 0,
176+
aic_quantity: 50,
177+
aic_gross_amount: 0.5,
178+
aic_net_amount: 0.5,
179+
})
180+
})
181+
182+
it('detects native AI Credits reports and routes them to an unsupported adapter', () => {
183+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
184+
const row = buildRow([
185+
'2026-06-01',
186+
'mona',
187+
'copilot',
188+
'copilot_ai_credit',
189+
'Auto: Claude Haiku 4.5',
190+
'96.9990345',
191+
'ai-credits',
192+
'0.01',
193+
'0.969990345',
194+
'0',
195+
'0.969990345',
196+
'3900',
197+
'example-org',
198+
'',
199+
'96.9990345',
200+
'0.969990345',
201+
])
202+
const record = parseTokenUsageRecord(
203+
row,
204+
header,
145205
)
146206

207+
const adapter = selectUsageReportAdapter(header, record)
208+
147209
expect(detectReportFormat(header, record)).toBe('native-ai-credits')
148-
expect(selectUsageReportAdapter(header, record).metadata).toMatchObject({
210+
expect(adapter.metadata).toMatchObject({
149211
format: 'native-ai-credits',
150212
supported: false,
151213
})
214+
expect(() => adapter.validateFirstRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError)
152215
expect(() => validateUsageReportFirstRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError)
216+
expect(adapter.parseRecord(row, header)).toMatchObject({
217+
date: '2026-06-01',
218+
quantity: 96.9990345,
219+
unit_type: 'ai-credits',
220+
aic_quantity: 96.9990345,
221+
aic_gross_amount: 0.969990345,
222+
aic_net_amount: 0.969990345,
223+
has_aic_quantity: true,
224+
has_aic_gross_amount: true,
225+
})
226+
})
227+
228+
it('normalizes native AI Credits dates through the unsupported adapter parser hook', () => {
229+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
230+
const row = buildRow([
231+
'2026-06-01',
232+
'mona',
233+
'copilot',
234+
'copilot_ai_credit',
235+
'Auto: Claude Haiku 4.5',
236+
'96.9990345',
237+
'ai-credits',
238+
'0.01',
239+
'0.969990345',
240+
'0',
241+
'0.969990345',
242+
'3900',
243+
'example-org',
244+
'',
245+
'96.9990345',
246+
'0.969990345',
247+
])
248+
const record = parseTokenUsageRecord(row, header)
249+
const adapter = selectUsageReportAdapter(header, record)
250+
251+
expect(adapter.parseRecord(row.replace('2026-06-01', '6/1/26'), header)?.date).toBe('2026-06-01')
153252
})
154253

155254
it('fails clearly for malformed billing headers before adapter selection', () => {

src/pipeline/reportAdapters.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
UnsupportedNativeAiCreditsReportError,
33
hasNativeAiCreditsReportSignature,
4+
parseNativeAiCreditsUsageRecord,
5+
parseNormalizedTokenUsageRecord,
46
validateHeader as validateTokenUsageHeader,
57
validateSupportedReportRecord,
68
type TokenUsageHeader,
@@ -19,6 +21,7 @@ export interface UsageReportAdapter {
1921
metadata: ReportFormatMetadata
2022
validateHeader(header: TokenUsageHeader): void
2123
validateFirstRecord(header: TokenUsageHeader, record: TokenUsageRecord): void
24+
parseRecord(line: string, header: TokenUsageHeader): TokenUsageRecord | null
2225
}
2326

2427
const TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER: UsageReportAdapter = {
@@ -33,6 +36,9 @@ const TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER: UsageReportAdapter = {
3336
validateFirstRecord(header, record) {
3437
validateSupportedReportRecord(header, record)
3538
},
39+
parseRecord(line, header) {
40+
return parseNormalizedTokenUsageRecord(line, header)
41+
},
3642
}
3743

3844
const NATIVE_AI_CREDITS_REPORT_ADAPTER: UsageReportAdapter = {
@@ -47,6 +53,9 @@ const NATIVE_AI_CREDITS_REPORT_ADAPTER: UsageReportAdapter = {
4753
validateFirstRecord() {
4854
throw new UnsupportedNativeAiCreditsReportError()
4955
},
56+
parseRecord(line, header) {
57+
return parseNativeAiCreditsUsageRecord(line, header)
58+
},
5059
}
5160

5261
const REPORT_ADAPTERS: Record<ReportFormat, UsageReportAdapter> = {

src/pipeline/runPipeline.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { createAicIncludedCreditsAllocator, type AicIncludedCreditsOverrides } f
33
import {
44
InvalidReportError,
55
parseTokenUsageHeader,
6-
parseNormalizedTokenUsageRecord,
76
parseTokenUsageRecord,
87
type TokenUsageHeader,
98
type TokenUsageRecord,
@@ -16,7 +15,7 @@ import {
1615
} from './reportAdapters'
1716
import { streamLines, type StreamProgress } from './streamer'
1817

19-
async function validateFileFormat(file: File): Promise<ReportFormatMetadata> {
18+
async function validateFileFormat(file: File): Promise<UsageReportAdapter> {
2019
let header: TokenUsageHeader | null = null
2120
let selectedAdapter: UsageReportAdapter | null = null
2221

@@ -32,14 +31,14 @@ async function validateFileFormat(file: File): Promise<ReportFormatMetadata> {
3231
continue
3332
}
3433

35-
return validateUsageReportFirstRecord(header, parseTokenUsageRecord(trimmed, header)).metadata
34+
return validateUsageReportFirstRecord(header, parseTokenUsageRecord(trimmed, header))
3635
}
3736

3837
if (!selectedAdapter) {
3938
throw new InvalidReportError()
4039
}
4140

42-
return selectedAdapter.metadata
41+
return selectedAdapter
4342
}
4443

4544
export interface PipelineProgress {
@@ -91,7 +90,8 @@ export async function runPipeline(
9190
options?: PipelineOptions,
9291
): Promise<PipelineResult> {
9392
const { includedCreditsOverrides = {}, progressResolution = 500, onProgress } = options ?? {}
94-
const reportMetadata = await validateFileFormat(file)
93+
const reportAdapter = await validateFileFormat(file)
94+
const reportMetadata = reportAdapter.metadata
9595
let lastProgressStage: PipelineProgress['stage'] | null = null
9696
let lastProgressPercent = -1
9797
let lastProgressTimestamp = 0
@@ -165,7 +165,7 @@ export async function runPipeline(
165165
continue
166166
}
167167

168-
const normalizedRecord = parseNormalizedTokenUsageRecord(trimmed, header)
168+
const normalizedRecord = reportAdapter.parseRecord(trimmed, header)
169169
reportRowCount += 1
170170
if (!normalizedRecord) continue
171171

0 commit comments

Comments
 (0)