Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 115 additions & 16 deletions src/pipeline/reportAdapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 9 additions & 0 deletions src/pipeline/reportAdapters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
UnsupportedNativeAiCreditsReportError,
hasNativeAiCreditsReportSignature,
parseNativeAiCreditsUsageRecord,
parseNormalizedTokenUsageRecord,
validateHeader as validateTokenUsageHeader,
validateSupportedReportRecord,
type TokenUsageHeader,
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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<ReportFormat, UsageReportAdapter> = {
Expand Down
12 changes: 6 additions & 6 deletions src/pipeline/runPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { createAicIncludedCreditsAllocator, type AicIncludedCreditsOverrides } f
import {
InvalidReportError,
parseTokenUsageHeader,
parseNormalizedTokenUsageRecord,
parseTokenUsageRecord,
type TokenUsageHeader,
type TokenUsageRecord,
Expand All @@ -16,7 +15,7 @@ import {
} from './reportAdapters'
import { streamLines, type StreamProgress } from './streamer'

async function validateFileFormat(file: File): Promise<ReportFormatMetadata> {
async function validateFileFormat(file: File): Promise<UsageReportAdapter> {
let header: TokenUsageHeader | null = null
let selectedAdapter: UsageReportAdapter | null = null

Expand All @@ -32,14 +31,14 @@ async function validateFileFormat(file: File): Promise<ReportFormatMetadata> {
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 {
Expand Down Expand Up @@ -91,7 +90,8 @@ export async function runPipeline(
options?: PipelineOptions,
): Promise<PipelineResult> {
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
Expand Down Expand Up @@ -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

Expand Down