Skip to content

Commit c7f4f98

Browse files
asizikovCopilot
andcommitted
feat: add native AI Credits parsing helpers
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 668177e commit c7f4f98

3 files changed

Lines changed: 164 additions & 5 deletions

File tree

src/pipeline/parser.test.ts

Lines changed: 91 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,95 @@ 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+
has_aic_quantity: true,
728+
has_aic_gross_amount: true,
729+
})
730+
})
731+
732+
it('uses native quantity and gross amount as AIC alias fallbacks when alias columns are blank', () => {
733+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
734+
const record = parseNativeAiCreditsUsageRecord(
735+
buildRow([
736+
'5/29/26',
737+
'hubot',
738+
'spark',
739+
'spark_ai_credit',
740+
'GPT-5.2',
741+
'12.5',
742+
'ai-credits',
743+
'0.01',
744+
'0.125',
745+
'0.025',
746+
'0.1',
747+
'7000',
748+
'octodemo',
749+
'',
750+
'',
751+
'',
752+
]),
753+
header,
754+
)
755+
756+
expect(record).toMatchObject({
757+
date: '2026-05-29',
758+
quantity: 12.5,
759+
gross_amount: 0.125,
760+
discount_amount: 0.025,
761+
net_amount: 0.1,
762+
aic_quantity: 12.5,
763+
aic_gross_amount: 0.125,
764+
has_aic_quantity: true,
765+
has_aic_gross_amount: true,
766+
})
767+
})
768+
})
769+
679770
describe('validateHeader', () => {
680771
it('accepts a header that contains all required columns', () => {
681772
const header = parseTokenUsageHeader(FULL_HEADER)

src/pipeline/parser.ts

Lines changed: 58 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,62 @@ 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 aicQuantity = record.has_aic_quantity ? record.aic_quantity : record.quantity
330+
const aicGrossAmount = record.has_aic_gross_amount ? record.aic_gross_amount : record.gross_amount
331+
const nativeRecord: TokenUsageRecord = {
332+
...record,
333+
date: normalizeNativeAiCreditsReportDate(record.date),
334+
aic_quantity: aicQuantity,
335+
aic_gross_amount: aicGrossAmount,
336+
has_aic_quantity: true,
337+
has_aic_gross_amount: true,
338+
}
339+
340+
nativeRecord.aic_net_amount = getAicUsageMetrics(nativeRecord).aicGrossAmount
341+
return nativeRecord
342+
}
343+
286344
function isAprilBackfillDate(date: string): boolean {
287345
return date >= APRIL_BACKFILL_START_DATE && date <= APRIL_BACKFILL_END_DATE
288346
}

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)