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
8 changes: 4 additions & 4 deletions src/pipeline/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,7 @@ describe('validateHeader', () => {
})

it('throws UnsupportedReportVersionError when only aic columns are missing', () => {
const legacyHeader = [
const preAicHeader = [
'date',
'username',
'product',
Expand All @@ -710,12 +710,12 @@ describe('validateHeader', () => {
'organization',
'cost_center_name',
].join(',')
const header = parseTokenUsageHeader(legacyHeader)
const header = parseTokenUsageHeader(preAicHeader)
expect(() => validateHeader(header)).toThrow(UnsupportedReportVersionError)
})

it('throws UnsupportedReportVersionError when only one aic column is missing', () => {
const legacyHeader = [
const preAicHeader = [
'date',
'username',
'product',
Expand All @@ -733,7 +733,7 @@ describe('validateHeader', () => {
'cost_center_name',
'aic_quantity',
].join(',')
const header = parseTokenUsageHeader(legacyHeader)
const header = parseTokenUsageHeader(preAicHeader)
expect(() => validateHeader(header)).toThrow(UnsupportedReportVersionError)
})

Expand Down
8 changes: 6 additions & 2 deletions src/pipeline/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,15 @@ export function validateHeader(header: TokenUsageHeader): void {
}
}

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

if (lacksExceedsQuota && usesNativeAiCreditsUnit) {
return lacksExceedsQuota && usesNativeAiCreditsUnit
}

export function validateSupportedReportRecord(header: TokenUsageHeader, record: TokenUsageRecord): void {
if (hasNativeAiCreditsReportSignature(header, record)) {
throw new UnsupportedNativeAiCreditsReportError()
}
}
Expand Down
182 changes: 182 additions & 0 deletions src/pipeline/reportAdapters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest'

import {
InvalidReportError,
UnsupportedNativeAiCreditsReportError,
UnsupportedReportVersionError,
parseTokenUsageHeader,
parseTokenUsageRecord,
} from './parser'
import {
detectReportFormat,
selectUsageReportAdapter,
validateUsageReportFirstRecord,
validateUsageReportHeader,
} from './reportAdapters'

const TRANSITION_PERIOD_HEADER = [
'date',
'username',
'product',
'sku',
'model',
'quantity',
'unit_type',
'applied_cost_per_quantity',
'gross_amount',
'discount_amount',
'net_amount',
'exceeds_quota',
'total_monthly_quota',
'organization',
'cost_center_name',
'aic_quantity',
'aic_gross_amount',
].join(',')

const HEADER_WITHOUT_EXCEEDS_QUOTA = [
'date',
'username',
'product',
'sku',
'model',
'quantity',
'unit_type',
'applied_cost_per_quantity',
'gross_amount',
'discount_amount',
'net_amount',
'total_monthly_quota',
'organization',
'cost_center_name',
'aic_quantity',
'aic_gross_amount',
].join(',')

function buildRow(values: string[]): string {
return values.join(',')
}

describe('usage report adapters', () => {
it('detects and selects the Transition Period Billing Preview adapter for current preview reports', () => {
const header = parseTokenUsageHeader(TRANSITION_PERIOD_HEADER)
const record = parseTokenUsageRecord(
buildRow([
'2026-05-29',
'mona',
'copilot',
'copilot_premium_request',
'Auto: Claude Haiku 4.5',
'2',
'requests',
'0.04',
'0.08',
'0',
'0.08',
'False',
'300',
'example-org',
'Cost Center A',
'20',
'0.20',
]),
header,
)

expect(detectReportFormat(header, record)).toBe('transition-period-billing-preview')
expect(selectUsageReportAdapter(header, record).metadata).toMatchObject({
format: 'transition-period-billing-preview',
supported: true,
})
expect(() => validateUsageReportFirstRecord(header, record)).not.toThrow()
})

it('keeps missing-exceeds premium request rows on the Transition Period Billing Preview adapter', () => {
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
const record = parseTokenUsageRecord(
buildRow([
'2026-05-29',
'mona',
'copilot',
'copilot_premium_request',
'Auto: Claude Haiku 4.5',
'2',
'requests',
'0.04',
'0.08',
'0',
'0.08',
'300',
'example-org',
'Cost Center A',
'20',
'0.20',
]),
header,
)

expect(detectReportFormat(header, record)).toBe('transition-period-billing-preview')
expect(selectUsageReportAdapter(header, record).metadata.format).toBe('transition-period-billing-preview')
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(
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',
]),
header,
)

expect(detectReportFormat(header, record)).toBe('native-ai-credits')
expect(selectUsageReportAdapter(header, record).metadata).toMatchObject({
format: 'native-ai-credits',
supported: false,
})
expect(() => validateUsageReportFirstRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError)
})

it('fails clearly for malformed billing headers before adapter selection', () => {
const header = parseTokenUsageHeader('foo,bar,baz')

expect(() => validateUsageReportHeader(header)).toThrow(InvalidReportError)
})

it('fails clearly for pre-AIC report headers before adapter selection', () => {
const header = parseTokenUsageHeader([
'date',
'username',
'product',
'sku',
'model',
'quantity',
'unit_type',
'applied_cost_per_quantity',
'gross_amount',
'discount_amount',
'net_amount',
'exceeds_quota',
'total_monthly_quota',
'organization',
'cost_center_name',
].join(','))

expect(() => validateUsageReportHeader(header)).toThrow(UnsupportedReportVersionError)
})
})
82 changes: 82 additions & 0 deletions src/pipeline/reportAdapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
UnsupportedNativeAiCreditsReportError,
hasNativeAiCreditsReportSignature,
validateHeader as validateTokenUsageHeader,
validateSupportedReportRecord,
type TokenUsageHeader,
type TokenUsageRecord,
} from './parser'

export type ReportFormat = 'transition-period-billing-preview' | 'native-ai-credits'

export type ReportFormatMetadata = {
format: ReportFormat
label: string
supported: boolean
}

export interface UsageReportAdapter {
metadata: ReportFormatMetadata
validateHeader(header: TokenUsageHeader): void
validateFirstRecord(header: TokenUsageHeader, record: TokenUsageRecord): void
}

const TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER: UsageReportAdapter = {
metadata: {
format: 'transition-period-billing-preview',
label: 'Transition Period Billing Preview report',
supported: true,
},
validateHeader(header) {
validateTokenUsageHeader(header)
},
validateFirstRecord(header, record) {
validateSupportedReportRecord(header, record)
},
}

const NATIVE_AI_CREDITS_REPORT_ADAPTER: UsageReportAdapter = {
metadata: {
format: 'native-ai-credits',
label: 'Native AI Credits report',
supported: false,
},
validateHeader(header) {
validateTokenUsageHeader(header)
},
validateFirstRecord() {
throw new UnsupportedNativeAiCreditsReportError()
},
}

const REPORT_ADAPTERS: Record<ReportFormat, UsageReportAdapter> = {
'transition-period-billing-preview': TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER,
'native-ai-credits': NATIVE_AI_CREDITS_REPORT_ADAPTER,
}

export function validateUsageReportHeader(header: TokenUsageHeader): void {
validateTokenUsageHeader(header)
}

export function detectReportFormat(header: TokenUsageHeader, firstRecord: TokenUsageRecord): ReportFormat {
// This intentionally mirrors the existing preflight check: format detection only samples the first data row.
if (hasNativeAiCreditsReportSignature(header, firstRecord)) {
return 'native-ai-credits'
}

return 'transition-period-billing-preview'
}

export function selectUsageReportAdapter(header: TokenUsageHeader, firstRecord: TokenUsageRecord): UsageReportAdapter {
return REPORT_ADAPTERS[detectReportFormat(header, firstRecord)]
}

export function validateUsageReportFirstRecord(
header: TokenUsageHeader,
firstRecord: TokenUsageRecord,
): UsageReportAdapter {
const adapter = selectUsageReportAdapter(header, firstRecord)
adapter.validateHeader(header)
adapter.validateFirstRecord(header, firstRecord)
return adapter
}
43 changes: 42 additions & 1 deletion src/pipeline/runPipeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'

import type { Aggregator } from './aggregators/base'
import type { TokenUsageHeader, TokenUsageRecord } from './parser'
import { InvalidReportError, UnsupportedReportVersionError, type TokenUsageHeader, type TokenUsageRecord } from './parser'
import { runPipeline } from './runPipeline'

const HEADER = [
Expand Down Expand Up @@ -65,6 +65,47 @@ class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord
}

describe('runPipeline', () => {
it('accepts a valid header-only report', async () => {
const aggregator = new CaptureAggregator()

await expect(runPipeline(createCsv([]), [aggregator])).resolves.toEqual({
reportRowCount: 0,
processedRowCount: 0,
})
expect(aggregator.result()).toEqual([])
})

it('rejects a malformed header-only report', async () => {
const aggregator = new CaptureAggregator()

await expect(runPipeline(createCsv([], 'foo,bar,baz'), [aggregator])).rejects.toThrow(InvalidReportError)
expect(aggregator.result()).toEqual([])
})

it('rejects a pre-AIC header-only report', async () => {
const header = [
'date',
'username',
'product',
'sku',
'model',
'quantity',
'unit_type',
'applied_cost_per_quantity',
'gross_amount',
'discount_amount',
'net_amount',
'exceeds_quota',
'total_monthly_quota',
'organization',
'cost_center_name',
].join(',')
const aggregator = new CaptureAggregator()

await expect(runPipeline(createCsv([], header), [aggregator])).rejects.toThrow(UnsupportedReportVersionError)
expect(aggregator.result()).toEqual([])
})

it('rejects native AI Credits reports before processing rows', async () => {
const file = createCsv([
[
Expand Down
Loading