From 82b210f38ddcc78f10959e801ddbc3249616a083 Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Fri, 5 Jun 2026 19:06:24 +0200 Subject: [PATCH] refactor: move report row parsing into adapters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/pipeline/reportAdapters.test.ts | 131 ++++++++++++++++++++++++---- src/pipeline/reportAdapters.ts | 9 ++ src/pipeline/runPipeline.ts | 12 +-- 3 files changed, 130 insertions(+), 22 deletions(-) diff --git a/src/pipeline/reportAdapters.test.ts b/src/pipeline/reportAdapters.test.ts index 93d7c51..5a48691 100644 --- a/src/pipeline/reportAdapters.test.ts +++ b/src/pipeline/reportAdapters.test.ts @@ -120,36 +120,135 @@ describe('usage report adapters', () => { expect(() => validateUsageReportFirstRecord(header, record)).not.toThrow() }) - it('detects native AI Credits reports and routes them to an unsupported adapter', () => { - const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA) - const record = parseTokenUsageRecord( + it('normalizes transition-period rows through the adapter parser', () => { + const header = parseTokenUsageHeader(TRANSITION_PERIOD_HEADER) + const adapter = validateUsageReportHeader(header) + + expect(adapter.parseRecord( buildRow([ - '2026-06-01', + '2026-04-25', 'mona', 'copilot', - 'copilot_ai_credit', - 'Auto: Claude Haiku 4.5', - '96.9990345', - 'ai-credits', - '0.01', - '0.969990345', + 'copilot_premium_request', + 'GPT-5', '0', - '0.969990345', - '3900', - 'example-org', + 'requests', + '0.04', + '0', + '0', + '0', + 'False', + '300', + '', + '', + '0', + '0', + ]), + header, + )).toBeNull() + + expect(adapter.parseRecord( + buildRow([ + '2026-04-25', + 'mona', + 'copilot', + 'copilot_premium_request', + 'GPT-5', + '10', + 'requests', + '0.04', + '0.40', + '0', + '0.40', + 'False', + '0', + '', '', - '96.9990345', - '0.969990345', + '100', + '1.00', ]), header, + )).toMatchObject({ + username: 'mona', + quantity: 0, + gross_amount: 0, + net_amount: 0, + aic_quantity: 50, + aic_gross_amount: 0.5, + aic_net_amount: 0.5, + }) + }) + + it('detects native AI Credits reports and routes them to an unsupported adapter', () => { + const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA) + const row = buildRow([ + '2026-06-01', + 'mona', + 'copilot', + 'copilot_ai_credit', + 'Auto: Claude Haiku 4.5', + '96.9990345', + 'ai-credits', + '0.01', + '0.969990345', + '0', + '0.969990345', + '3900', + 'example-org', + '', + '96.9990345', + '0.969990345', + ]) + const record = parseTokenUsageRecord( + row, + header, ) + const adapter = selectUsageReportAdapter(header, record) + expect(detectReportFormat(header, record)).toBe('native-ai-credits') - expect(selectUsageReportAdapter(header, record).metadata).toMatchObject({ + expect(adapter.metadata).toMatchObject({ format: 'native-ai-credits', supported: false, }) + expect(() => adapter.validateFirstRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError) expect(() => validateUsageReportFirstRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError) + expect(adapter.parseRecord(row, header)).toMatchObject({ + date: '2026-06-01', + quantity: 96.9990345, + unit_type: 'ai-credits', + aic_quantity: 96.9990345, + aic_gross_amount: 0.969990345, + aic_net_amount: 0.969990345, + has_aic_quantity: true, + has_aic_gross_amount: true, + }) + }) + + it('normalizes native AI Credits dates through the unsupported adapter parser hook', () => { + const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA) + const row = buildRow([ + '2026-06-01', + 'mona', + 'copilot', + 'copilot_ai_credit', + 'Auto: Claude Haiku 4.5', + '96.9990345', + 'ai-credits', + '0.01', + '0.969990345', + '0', + '0.969990345', + '3900', + 'example-org', + '', + '96.9990345', + '0.969990345', + ]) + const record = parseTokenUsageRecord(row, header) + const adapter = selectUsageReportAdapter(header, record) + + expect(adapter.parseRecord(row.replace('2026-06-01', '6/1/26'), header)?.date).toBe('2026-06-01') }) it('fails clearly for malformed billing headers before adapter selection', () => { diff --git a/src/pipeline/reportAdapters.ts b/src/pipeline/reportAdapters.ts index 711a69e..1f12ed3 100644 --- a/src/pipeline/reportAdapters.ts +++ b/src/pipeline/reportAdapters.ts @@ -1,6 +1,8 @@ import { UnsupportedNativeAiCreditsReportError, hasNativeAiCreditsReportSignature, + parseNativeAiCreditsUsageRecord, + parseNormalizedTokenUsageRecord, validateHeader as validateTokenUsageHeader, validateSupportedReportRecord, type TokenUsageHeader, @@ -19,6 +21,7 @@ export interface UsageReportAdapter { metadata: ReportFormatMetadata validateHeader(header: TokenUsageHeader): void validateFirstRecord(header: TokenUsageHeader, record: TokenUsageRecord): void + parseRecord(line: string, header: TokenUsageHeader): TokenUsageRecord | null } const TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER: UsageReportAdapter = { @@ -33,6 +36,9 @@ const TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER: UsageReportAdapter = { validateFirstRecord(header, record) { validateSupportedReportRecord(header, record) }, + parseRecord(line, header) { + return parseNormalizedTokenUsageRecord(line, header) + }, } const NATIVE_AI_CREDITS_REPORT_ADAPTER: UsageReportAdapter = { @@ -47,6 +53,9 @@ const NATIVE_AI_CREDITS_REPORT_ADAPTER: UsageReportAdapter = { validateFirstRecord() { throw new UnsupportedNativeAiCreditsReportError() }, + parseRecord(line, header) { + return parseNativeAiCreditsUsageRecord(line, header) + }, } const REPORT_ADAPTERS: Record = { diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts index 5e3510d..d33964a 100644 --- a/src/pipeline/runPipeline.ts +++ b/src/pipeline/runPipeline.ts @@ -3,7 +3,6 @@ import { createAicIncludedCreditsAllocator, type AicIncludedCreditsOverrides } f import { InvalidReportError, parseTokenUsageHeader, - parseNormalizedTokenUsageRecord, parseTokenUsageRecord, type TokenUsageHeader, type TokenUsageRecord, @@ -16,7 +15,7 @@ import { } from './reportAdapters' import { streamLines, type StreamProgress } from './streamer' -async function validateFileFormat(file: File): Promise { +async function validateFileFormat(file: File): Promise { let header: TokenUsageHeader | null = null let selectedAdapter: UsageReportAdapter | null = null @@ -32,14 +31,14 @@ async function validateFileFormat(file: File): Promise { continue } - return validateUsageReportFirstRecord(header, parseTokenUsageRecord(trimmed, header)).metadata + return validateUsageReportFirstRecord(header, parseTokenUsageRecord(trimmed, header)) } if (!selectedAdapter) { throw new InvalidReportError() } - return selectedAdapter.metadata + return selectedAdapter } export interface PipelineProgress { @@ -91,7 +90,8 @@ export async function runPipeline( options?: PipelineOptions, ): Promise { const { includedCreditsOverrides = {}, progressResolution = 500, onProgress } = options ?? {} - const reportMetadata = await validateFileFormat(file) + const reportAdapter = await validateFileFormat(file) + const reportMetadata = reportAdapter.metadata let lastProgressStage: PipelineProgress['stage'] | null = null let lastProgressPercent = -1 let lastProgressTimestamp = 0 @@ -165,7 +165,7 @@ export async function runPipeline( continue } - const normalizedRecord = parseNormalizedTokenUsageRecord(trimmed, header) + const normalizedRecord = reportAdapter.parseRecord(trimmed, header) reportRowCount += 1 if (!normalizedRecord) continue