Skip to content

Commit 7f5a5d6

Browse files
authored
Merge pull request #135 from github/asizikov/native-ai-credits-parsing-tests
Add native AI Credits parsing helpers
2 parents 668177e + 4276180 commit 7f5a5d6

3 files changed

Lines changed: 201 additions & 5 deletions

File tree

src/pipeline/parser.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'
22
import {
33
getUsageMetrics,
44
InvalidReportError,
5+
normalizeNativeAiCreditsReportDate,
56
normalizeTokenUsageRecord,
67
parseCsvRow,
8+
parseNativeAiCreditsUsageRecord,
79
parseNormalizedTokenUsageRecord,
810
parseTokenUsageHeader,
911
parseTokenUsageRecord,
@@ -676,6 +678,134 @@ describe('parser and metric normalization', () => {
676678
})
677679
})
678680

681+
describe('native AI Credits parsing helpers', () => {
682+
it('normalizes native short dates to ISO dates', () => {
683+
expect(normalizeNativeAiCreditsReportDate('5/29/26')).toBe('2026-05-29')
684+
expect(normalizeNativeAiCreditsReportDate('05/09/2026')).toBe('2026-05-09')
685+
expect(normalizeNativeAiCreditsReportDate('2026-05-29')).toBe('2026-05-29')
686+
})
687+
688+
it('parses native AI Credits usage and cost fields without enabling pipeline support', () => {
689+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
690+
const record = parseNativeAiCreditsUsageRecord(
691+
buildRow([
692+
'5/29/26',
693+
'mona',
694+
'copilot',
695+
'copilot_ai_credit',
696+
'Auto: Claude Haiku 4.5',
697+
'96.9990345',
698+
'ai-credits',
699+
'0.01',
700+
'0.969990345',
701+
'0.15',
702+
'0.819990345',
703+
'3900',
704+
'example-org',
705+
'Cost Center A',
706+
'96.9990345',
707+
'0.969990345',
708+
]),
709+
header,
710+
)
711+
712+
expect(record).toMatchObject({
713+
date: '2026-05-29',
714+
username: 'mona',
715+
product: 'copilot',
716+
sku: 'copilot_ai_credit',
717+
quantity: 96.9990345,
718+
unit_type: 'ai-credits',
719+
gross_amount: 0.969990345,
720+
discount_amount: 0.15,
721+
net_amount: 0.819990345,
722+
total_monthly_quota: 3900,
723+
organization: 'example-org',
724+
cost_center_name: 'Cost Center A',
725+
aic_quantity: 96.9990345,
726+
aic_gross_amount: 0.969990345,
727+
aic_net_amount: 0.819990345,
728+
has_aic_quantity: true,
729+
has_aic_gross_amount: true,
730+
})
731+
})
732+
733+
it('uses native quantity and cost fields as AIC aliases when alias columns are blank', () => {
734+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
735+
const record = parseNativeAiCreditsUsageRecord(
736+
buildRow([
737+
'5/29/26',
738+
'hubot',
739+
'spark',
740+
'spark_ai_credit',
741+
'GPT-5.2',
742+
'12.5',
743+
'ai-credits',
744+
'0.01',
745+
'0.125',
746+
'0.025',
747+
'0.1',
748+
'7000',
749+
'octodemo',
750+
'',
751+
'',
752+
'',
753+
]),
754+
header,
755+
)
756+
757+
expect(record).toMatchObject({
758+
date: '2026-05-29',
759+
quantity: 12.5,
760+
gross_amount: 0.125,
761+
discount_amount: 0.025,
762+
net_amount: 0.1,
763+
aic_quantity: 12.5,
764+
aic_gross_amount: 0.125,
765+
aic_net_amount: 0.1,
766+
has_aic_quantity: true,
767+
has_aic_gross_amount: true,
768+
})
769+
})
770+
771+
it('keeps native quantity and cost fields authoritative when alias columns differ', () => {
772+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
773+
const record = parseNativeAiCreditsUsageRecord(
774+
buildRow([
775+
'5/29/26',
776+
'octocat',
777+
'copilot',
778+
'copilot_ai_credit',
779+
'GPT-5.2',
780+
'50',
781+
'ai-credits',
782+
'0.01',
783+
'0.50',
784+
'0.20',
785+
'0.30',
786+
'3900',
787+
'example-org',
788+
'Cost Center A',
789+
'75',
790+
'0.75',
791+
]),
792+
header,
793+
)
794+
795+
expect(record).toMatchObject({
796+
quantity: 50,
797+
gross_amount: 0.5,
798+
discount_amount: 0.2,
799+
net_amount: 0.3,
800+
aic_quantity: 50,
801+
aic_gross_amount: 0.5,
802+
aic_net_amount: 0.3,
803+
has_aic_quantity: true,
804+
has_aic_gross_amount: true,
805+
})
806+
})
807+
})
808+
679809
describe('validateHeader', () => {
680810
it('accepts a header that contains all required columns', () => {
681811
const header = parseTokenUsageHeader(FULL_HEADER)

src/pipeline/parser.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ const BASE_BILLING_COLUMNS = [
117117
const REQUIRED_AIC_COLUMNS = ['aic_quantity', 'aic_gross_amount'] as const
118118
const APRIL_BACKFILL_START_DATE = '2026-04-24'
119119
const APRIL_BACKFILL_END_DATE = '2026-04-30'
120+
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
121+
const SLASH_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/
120122

121123
export class InvalidReportError extends Error {
122124
constructor() {
@@ -283,6 +285,60 @@ export function parseTokenUsageRecord(line: string, header: TokenUsageHeader): T
283285
return record
284286
}
285287

288+
function isValidDateParts(year: number, month: number, day: number): boolean {
289+
const date = new Date(Date.UTC(year, month - 1, day))
290+
return (
291+
date.getUTCFullYear() === year
292+
&& date.getUTCMonth() === month - 1
293+
&& date.getUTCDate() === day
294+
)
295+
}
296+
297+
function formatIsoDate(year: number, month: number, day: number): string {
298+
return [
299+
String(year).padStart(4, '0'),
300+
String(month).padStart(2, '0'),
301+
String(day).padStart(2, '0'),
302+
].join('-')
303+
}
304+
305+
export function normalizeNativeAiCreditsReportDate(rawDate: string): string {
306+
const date = rawDate.trim()
307+
const isoMatch = ISO_DATE_PATTERN.exec(date)
308+
if (isoMatch) {
309+
const year = Number(isoMatch[1])
310+
const month = Number(isoMatch[2])
311+
const day = Number(isoMatch[3])
312+
return isValidDateParts(year, month, day) ? date : rawDate.trim()
313+
}
314+
315+
const slashMatch = SLASH_DATE_PATTERN.exec(date)
316+
if (!slashMatch) return date
317+
318+
const month = Number(slashMatch[1])
319+
const day = Number(slashMatch[2])
320+
const yearRaw = slashMatch[3]
321+
const year = yearRaw.length === 2 ? 2000 + Number(yearRaw) : Number(yearRaw)
322+
323+
if (!isValidDateParts(year, month, day)) return date
324+
return formatIsoDate(year, month, day)
325+
}
326+
327+
export function parseNativeAiCreditsUsageRecord(line: string, header: TokenUsageHeader): TokenUsageRecord {
328+
const record = parseTokenUsageRecord(line, header)
329+
const nativeRecord: TokenUsageRecord = {
330+
...record,
331+
date: normalizeNativeAiCreditsReportDate(record.date),
332+
aic_quantity: record.quantity,
333+
aic_gross_amount: record.gross_amount,
334+
aic_net_amount: record.net_amount,
335+
has_aic_quantity: true,
336+
has_aic_gross_amount: true,
337+
}
338+
339+
return nativeRecord
340+
}
341+
286342
function isAprilBackfillDate(date: string): boolean {
287343
return date >= APRIL_BACKFILL_START_DATE && date <= APRIL_BACKFILL_END_DATE
288344
}

src/pipeline/runPipeline.test.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { describe, expect, it } from 'vitest'
22

33
import type { Aggregator } from './aggregators/base'
4-
import { InvalidReportError, UnsupportedReportVersionError, type TokenUsageHeader, type TokenUsageRecord } from './parser'
4+
import {
5+
InvalidReportError,
6+
UnsupportedNativeAiCreditsReportError,
7+
UnsupportedReportVersionError,
8+
type TokenUsageHeader,
9+
type TokenUsageRecord,
10+
} from './parser'
511
import { runPipeline } from './runPipeline'
612

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

5763
class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord[], TokenUsageHeader> {
5864
private readonly records: TokenUsageRecord[] = []
65+
private headerCallCount = 0
5966

6067
onHeader(): void {
61-
// no-op
68+
this.headerCallCount += 1
6269
}
6370

6471
accumulate(record: TokenUsageRecord): void {
@@ -68,6 +75,10 @@ class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord
6875
result(): TokenUsageRecord[] {
6976
return this.records
7077
}
78+
79+
headerCalls(): number {
80+
return this.headerCallCount
81+
}
7182
}
7283

7384
describe('runPipeline', () => {
@@ -144,9 +155,8 @@ describe('runPipeline', () => {
144155
], NATIVE_AI_CREDITS_HEADER)
145156
const aggregator = new CaptureAggregator()
146157

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

0 commit comments

Comments
 (0)