Skip to content

Commit f309b4d

Browse files
asizikovCopilot
andcommitted
fix: reject native AI Credits reports
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 185dcbf commit f309b4d

4 files changed

Lines changed: 149 additions & 8 deletions

File tree

src/pipeline/parser.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
parseNormalizedTokenUsageRecord,
88
parseTokenUsageHeader,
99
parseTokenUsageRecord,
10+
UnsupportedNativeAiCreditsReportError,
1011
UnsupportedReportVersionError,
1112
validateHeader,
13+
validateSupportedReportRecord,
1214
} from './parser'
1315

1416
const FULL_HEADER = [
@@ -759,3 +761,62 @@ describe('validateHeader', () => {
759761
expect(() => validateHeader(header)).toThrow(InvalidReportError)
760762
})
761763
})
764+
765+
describe('validateSupportedReportRecord', () => {
766+
it('throws a clear error for the native AI Credits report format', () => {
767+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
768+
const record = parseTokenUsageRecord(
769+
buildRow([
770+
'5/29/26',
771+
'mona',
772+
'copilot',
773+
'copilot_ai_credit',
774+
'Auto: Claude Haiku 4.5',
775+
'96.9990345',
776+
'ai-credits',
777+
'0.01',
778+
'0.969990345',
779+
'0',
780+
'0.969990345',
781+
'3900',
782+
'example-org',
783+
'',
784+
'96.9990345',
785+
'0.969990345',
786+
]),
787+
header,
788+
)
789+
790+
expect(() => validateSupportedReportRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError)
791+
expect(() => validateSupportedReportRecord(header, record)).toThrow(
792+
'currently supports PRU vs usage-based billing reports generated for the April and May billing periods',
793+
)
794+
})
795+
796+
it('accepts PRU report rows when exceeds_quota is absent', () => {
797+
const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
798+
const record = parseTokenUsageRecord(
799+
buildRow([
800+
'2026-05-29',
801+
'mona',
802+
'copilot',
803+
'copilot_premium_request',
804+
'Auto: Claude Haiku 4.5',
805+
'2',
806+
'requests',
807+
'0.04',
808+
'0.08',
809+
'0',
810+
'0.08',
811+
'300',
812+
'example-org',
813+
'Cost Center A',
814+
'20',
815+
'0.20',
816+
]),
817+
header,
818+
)
819+
820+
expect(() => validateSupportedReportRecord(header, record)).not.toThrow()
821+
})
822+
})

src/pipeline/parser.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,17 @@ export class UnsupportedReportVersionError extends Error {
138138
}
139139
}
140140

141+
export class UnsupportedNativeAiCreditsReportError extends Error {
142+
constructor() {
143+
super(
144+
`This billing preview app currently supports PRU vs usage-based billing reports generated for ` +
145+
`the April and May billing periods. Reports generated on or after June 1 use AI Credits as ` +
146+
`the primary unit and are not supported yet.`,
147+
)
148+
this.name = 'UnsupportedNativeAiCreditsReportError'
149+
}
150+
}
151+
141152
export function validateHeader(header: TokenUsageHeader): void {
142153
const missingBase = BASE_BILLING_COLUMNS.filter((col) => !(col in header.index))
143154
if (missingBase.length > 0) {
@@ -150,6 +161,15 @@ export function validateHeader(header: TokenUsageHeader): void {
150161
}
151162
}
152163

164+
export function validateSupportedReportRecord(header: TokenUsageHeader, record: TokenUsageRecord): void {
165+
const lacksExceedsQuota = !('exceeds_quota' in header.index)
166+
const usesNativeAiCreditsUnit = record.unit_type === 'ai-credits' && record.sku.endsWith('_ai_credit')
167+
168+
if (lacksExceedsQuota && usesNativeAiCreditsUnit) {
169+
throw new UnsupportedNativeAiCreditsReportError()
170+
}
171+
}
172+
153173
function stripBom(s: string): string {
154174
return s.replace(/^\uFEFF/, '')
155175
}

src/pipeline/runPipeline.test.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,27 @@ const HEADER = [
2424
'aic_gross_amount',
2525
].join(',')
2626

27-
function createCsv(rows: string[][]): File {
28-
const body = [HEADER, ...rows.map((row) => row.join(','))].join('\n')
27+
const NATIVE_AI_CREDITS_HEADER = [
28+
'date',
29+
'username',
30+
'product',
31+
'sku',
32+
'model',
33+
'quantity',
34+
'unit_type',
35+
'applied_cost_per_quantity',
36+
'gross_amount',
37+
'discount_amount',
38+
'net_amount',
39+
'total_monthly_quota',
40+
'organization',
41+
'cost_center_name',
42+
'aic_quantity',
43+
'aic_gross_amount',
44+
].join(',')
45+
46+
function createCsv(rows: string[][], header = HEADER): File {
47+
const body = [header, ...rows.map((row) => row.join(','))].join('\n')
2948
return new File([body], 'usage.csv', { type: 'text/csv' })
3049
}
3150

@@ -45,7 +64,36 @@ class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord
4564
}
4665
}
4766

48-
describe('runPipeline progress', () => {
67+
describe('runPipeline', () => {
68+
it('rejects native AI Credits reports before processing rows', async () => {
69+
const file = createCsv([
70+
[
71+
'5/29/26',
72+
'mona',
73+
'copilot',
74+
'copilot_ai_credit',
75+
'Auto: Claude Haiku 4.5',
76+
'96.9990345',
77+
'ai-credits',
78+
'0.01',
79+
'0.969990345',
80+
'0',
81+
'0.969990345',
82+
'3900',
83+
'example-org',
84+
'',
85+
'96.9990345',
86+
'0.969990345',
87+
],
88+
], NATIVE_AI_CREDITS_HEADER)
89+
const aggregator = new CaptureAggregator()
90+
91+
await expect(runPipeline(file, [aggregator])).rejects.toThrow(
92+
'currently supports PRU vs usage-based billing reports generated for the April and May billing periods',
93+
)
94+
expect(aggregator.result()).toEqual([])
95+
})
96+
4997
it('filters and normalizes known normalization window rows before AIC allocation', async () => {
5098
const file = createCsv([
5199
['2026-04-25', 'mona', 'copilot', 'copilot_premium_request', 'GPT-5', '0', 'requests', '0.04', '0', '0', '0', 'False', '300', '', '', '0', '0'],

src/pipeline/runPipeline.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,31 @@ import { createAicIncludedCreditsAllocator, type AicIncludedCreditsOverrides } f
33
import {
44
parseTokenUsageHeader,
55
parseNormalizedTokenUsageRecord,
6+
parseTokenUsageRecord,
7+
validateSupportedReportRecord,
68
validateHeader,
79
type TokenUsageHeader,
810
type TokenUsageRecord,
911
} from './parser'
1012
import { streamLines, type StreamProgress } from './streamer'
1113

12-
async function validateFileHeader(file: File): Promise<void> {
14+
async function validateFileFormat(file: File): Promise<void> {
15+
let header: TokenUsageHeader | null = null
16+
1317
for await (const line of streamLines(file)) {
1418
const trimmed = line.trimEnd()
15-
if (trimmed) {
16-
validateHeader(parseTokenUsageHeader(trimmed))
17-
return
19+
if (!trimmed) {
20+
continue
1821
}
22+
23+
if (!header) {
24+
header = parseTokenUsageHeader(trimmed)
25+
validateHeader(header)
26+
continue
27+
}
28+
29+
validateSupportedReportRecord(header, parseTokenUsageRecord(trimmed, header))
30+
return
1931
}
2032
}
2133

@@ -67,7 +79,7 @@ export async function runPipeline(
6779
options?: PipelineOptions,
6880
): Promise<PipelineResult> {
6981
const { includedCreditsOverrides = {}, progressResolution = 500, onProgress } = options ?? {}
70-
await validateFileHeader(file)
82+
await validateFileFormat(file)
7183
let lastProgressStage: PipelineProgress['stage'] | null = null
7284
let lastProgressPercent = -1
7385
let lastProgressTimestamp = 0

0 commit comments

Comments
 (0)