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
130 changes: 130 additions & 0 deletions src/pipeline/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
getUsageMetrics,
InvalidReportError,
normalizeNativeAiCreditsReportDate,
normalizeTokenUsageRecord,
parseCsvRow,
parseNativeAiCreditsUsageRecord,
parseNormalizedTokenUsageRecord,
parseTokenUsageHeader,
parseTokenUsageRecord,
Expand Down Expand Up @@ -676,6 +678,134 @@ describe('parser and metric normalization', () => {
})
})

describe('native AI Credits parsing helpers', () => {
it('normalizes native short dates to ISO dates', () => {
expect(normalizeNativeAiCreditsReportDate('5/29/26')).toBe('2026-05-29')
expect(normalizeNativeAiCreditsReportDate('05/09/2026')).toBe('2026-05-09')
expect(normalizeNativeAiCreditsReportDate('2026-05-29')).toBe('2026-05-29')
})

it('parses native AI Credits usage and cost fields without enabling pipeline support', () => {
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
const record = parseNativeAiCreditsUsageRecord(
buildRow([
'5/29/26',
'mona',
'copilot',
'copilot_ai_credit',
'Auto: Claude Haiku 4.5',
'96.9990345',
'ai-credits',
'0.01',
'0.969990345',
'0.15',
'0.819990345',
'3900',
'example-org',
'Cost Center A',
'96.9990345',
'0.969990345',
]),
header,
)

expect(record).toMatchObject({
date: '2026-05-29',
username: 'mona',
product: 'copilot',
sku: 'copilot_ai_credit',
quantity: 96.9990345,
unit_type: 'ai-credits',
gross_amount: 0.969990345,
discount_amount: 0.15,
net_amount: 0.819990345,
total_monthly_quota: 3900,
organization: 'example-org',
cost_center_name: 'Cost Center A',
aic_quantity: 96.9990345,
aic_gross_amount: 0.969990345,
aic_net_amount: 0.819990345,
has_aic_quantity: true,
has_aic_gross_amount: true,
})
})

it('uses native quantity and cost fields as AIC aliases when alias columns are blank', () => {
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
const record = parseNativeAiCreditsUsageRecord(
buildRow([
'5/29/26',
'hubot',
'spark',
'spark_ai_credit',
'GPT-5.2',
'12.5',
'ai-credits',
'0.01',
'0.125',
'0.025',
'0.1',
'7000',
'octodemo',
'',
'',
'',
]),
header,
)

expect(record).toMatchObject({
date: '2026-05-29',
quantity: 12.5,
gross_amount: 0.125,
discount_amount: 0.025,
net_amount: 0.1,
aic_quantity: 12.5,
aic_gross_amount: 0.125,
aic_net_amount: 0.1,
has_aic_quantity: true,
has_aic_gross_amount: true,
})
})

it('keeps native quantity and cost fields authoritative when alias columns differ', () => {
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
const record = parseNativeAiCreditsUsageRecord(
buildRow([
'5/29/26',
'octocat',
'copilot',
'copilot_ai_credit',
'GPT-5.2',
'50',
'ai-credits',
'0.01',
'0.50',
'0.20',
'0.30',
'3900',
'example-org',
'Cost Center A',
'75',
'0.75',
]),
header,
)

expect(record).toMatchObject({
quantity: 50,
gross_amount: 0.5,
discount_amount: 0.2,
net_amount: 0.3,
aic_quantity: 50,
aic_gross_amount: 0.5,
aic_net_amount: 0.3,
has_aic_quantity: true,
has_aic_gross_amount: true,
})
})
})

describe('validateHeader', () => {
it('accepts a header that contains all required columns', () => {
const header = parseTokenUsageHeader(FULL_HEADER)
Expand Down
56 changes: 56 additions & 0 deletions src/pipeline/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ const BASE_BILLING_COLUMNS = [
const REQUIRED_AIC_COLUMNS = ['aic_quantity', 'aic_gross_amount'] as const
const APRIL_BACKFILL_START_DATE = '2026-04-24'
const APRIL_BACKFILL_END_DATE = '2026-04-30'
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
const SLASH_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/

export class InvalidReportError extends Error {
constructor() {
Expand Down Expand Up @@ -283,6 +285,60 @@ export function parseTokenUsageRecord(line: string, header: TokenUsageHeader): T
return record
}

function isValidDateParts(year: number, month: number, day: number): boolean {
const date = new Date(Date.UTC(year, month - 1, day))
return (
date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
)
}

function formatIsoDate(year: number, month: number, day: number): string {
return [
String(year).padStart(4, '0'),
String(month).padStart(2, '0'),
String(day).padStart(2, '0'),
].join('-')
}

export function normalizeNativeAiCreditsReportDate(rawDate: string): string {
const date = rawDate.trim()
const isoMatch = ISO_DATE_PATTERN.exec(date)
if (isoMatch) {
const year = Number(isoMatch[1])
const month = Number(isoMatch[2])
const day = Number(isoMatch[3])
return isValidDateParts(year, month, day) ? date : rawDate.trim()
}

const slashMatch = SLASH_DATE_PATTERN.exec(date)
if (!slashMatch) return date

const month = Number(slashMatch[1])
const day = Number(slashMatch[2])
const yearRaw = slashMatch[3]
const year = yearRaw.length === 2 ? 2000 + Number(yearRaw) : Number(yearRaw)

if (!isValidDateParts(year, month, day)) return date
return formatIsoDate(year, month, day)
}

export function parseNativeAiCreditsUsageRecord(line: string, header: TokenUsageHeader): TokenUsageRecord {
const record = parseTokenUsageRecord(line, header)
const nativeRecord: TokenUsageRecord = {
...record,
date: normalizeNativeAiCreditsReportDate(record.date),
aic_quantity: record.quantity,
aic_gross_amount: record.gross_amount,
aic_net_amount: record.net_amount,
has_aic_quantity: true,
has_aic_gross_amount: true,
}

return nativeRecord
}
Comment thread
asizikov marked this conversation as resolved.

function isAprilBackfillDate(date: string): boolean {
return date >= APRIL_BACKFILL_START_DATE && date <= APRIL_BACKFILL_END_DATE
}
Expand Down
20 changes: 15 additions & 5 deletions src/pipeline/runPipeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest'

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

const HEADER = [
Expand Down Expand Up @@ -56,9 +62,10 @@ function createCsv(rows: string[][], header = HEADER): File {

class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord[], TokenUsageHeader> {
private readonly records: TokenUsageRecord[] = []
private headerCallCount = 0

onHeader(): void {
// no-op
this.headerCallCount += 1
}

accumulate(record: TokenUsageRecord): void {
Expand All @@ -68,6 +75,10 @@ class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord
result(): TokenUsageRecord[] {
return this.records
}

headerCalls(): number {
return this.headerCallCount
}
}

describe('runPipeline', () => {
Expand Down Expand Up @@ -144,9 +155,8 @@ describe('runPipeline', () => {
], NATIVE_AI_CREDITS_HEADER)
const aggregator = new CaptureAggregator()

await expect(runPipeline(file, [aggregator])).rejects.toThrow(
'currently supports PRU vs usage-based billing reports generated for the April and May billing periods',
)
await expect(runPipeline(file, [aggregator])).rejects.toThrow(UnsupportedNativeAiCreditsReportError)
expect(aggregator.headerCalls()).toBe(0)
expect(aggregator.result()).toEqual([])
})

Expand Down