From f309b4dccb38e8d443f54dffc92c06a2d3e0c25e Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Sat, 30 May 2026 23:34:14 +0200 Subject: [PATCH 1/2] fix: reject native AI Credits reports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/pipeline/parser.test.ts | 61 ++++++++++++++++++++++++++++++++ src/pipeline/parser.ts | 20 +++++++++++ src/pipeline/runPipeline.test.ts | 54 ++++++++++++++++++++++++++-- src/pipeline/runPipeline.ts | 22 +++++++++--- 4 files changed, 149 insertions(+), 8 deletions(-) diff --git a/src/pipeline/parser.test.ts b/src/pipeline/parser.test.ts index afa5ffa..334cc22 100644 --- a/src/pipeline/parser.test.ts +++ b/src/pipeline/parser.test.ts @@ -7,8 +7,10 @@ import { parseNormalizedTokenUsageRecord, parseTokenUsageHeader, parseTokenUsageRecord, + UnsupportedNativeAiCreditsReportError, UnsupportedReportVersionError, validateHeader, + validateSupportedReportRecord, } from './parser' const FULL_HEADER = [ @@ -759,3 +761,62 @@ describe('validateHeader', () => { expect(() => validateHeader(header)).toThrow(InvalidReportError) }) }) + +describe('validateSupportedReportRecord', () => { + it('throws a clear error for the native AI Credits report format', () => { + const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA) + const record = parseTokenUsageRecord( + buildRow([ + '5/29/26', + '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(() => validateSupportedReportRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError) + expect(() => validateSupportedReportRecord(header, record)).toThrow( + 'currently supports PRU vs usage-based billing reports generated for the April and May billing periods', + ) + }) + + it('accepts PRU report rows when exceeds_quota is absent', () => { + 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(() => validateSupportedReportRecord(header, record)).not.toThrow() + }) +}) diff --git a/src/pipeline/parser.ts b/src/pipeline/parser.ts index b60d775..7e9e96f 100644 --- a/src/pipeline/parser.ts +++ b/src/pipeline/parser.ts @@ -138,6 +138,17 @@ export class UnsupportedReportVersionError extends Error { } } +export class UnsupportedNativeAiCreditsReportError extends Error { + constructor() { + super( + `This billing preview app currently supports PRU vs usage-based billing reports generated for ` + + `the April and May billing periods. Reports generated on or after June 1 use AI Credits as ` + + `the primary unit and are not supported yet.`, + ) + this.name = 'UnsupportedNativeAiCreditsReportError' + } +} + export function validateHeader(header: TokenUsageHeader): void { const missingBase = BASE_BILLING_COLUMNS.filter((col) => !(col in header.index)) if (missingBase.length > 0) { @@ -150,6 +161,15 @@ export function validateHeader(header: TokenUsageHeader): void { } } +export function validateSupportedReportRecord(header: TokenUsageHeader, record: TokenUsageRecord): void { + const lacksExceedsQuota = !('exceeds_quota' in header.index) + const usesNativeAiCreditsUnit = record.unit_type === 'ai-credits' && record.sku.endsWith('_ai_credit') + + if (lacksExceedsQuota && usesNativeAiCreditsUnit) { + throw new UnsupportedNativeAiCreditsReportError() + } +} + function stripBom(s: string): string { return s.replace(/^\uFEFF/, '') } diff --git a/src/pipeline/runPipeline.test.ts b/src/pipeline/runPipeline.test.ts index 43b5fa4..1bbdb09 100644 --- a/src/pipeline/runPipeline.test.ts +++ b/src/pipeline/runPipeline.test.ts @@ -24,8 +24,27 @@ const HEADER = [ 'aic_gross_amount', ].join(',') -function createCsv(rows: string[][]): File { - const body = [HEADER, ...rows.map((row) => row.join(','))].join('\n') +const NATIVE_AI_CREDITS_HEADER = [ + '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 createCsv(rows: string[][], header = HEADER): File { + const body = [header, ...rows.map((row) => row.join(','))].join('\n') return new File([body], 'usage.csv', { type: 'text/csv' }) } @@ -45,7 +64,36 @@ class CaptureAggregator implements Aggregator { +describe('runPipeline', () => { + it('rejects native AI Credits reports before processing rows', async () => { + const file = createCsv([ + [ + '5/29/26', + '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', + ], + ], 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', + ) + expect(aggregator.result()).toEqual([]) + }) + it('filters and normalizes known normalization window rows before AIC allocation', async () => { const file = createCsv([ ['2026-04-25', 'mona', 'copilot', 'copilot_premium_request', 'GPT-5', '0', 'requests', '0.04', '0', '0', '0', 'False', '300', '', '', '0', '0'], diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts index 08dbb7e..d5ce7ea 100644 --- a/src/pipeline/runPipeline.ts +++ b/src/pipeline/runPipeline.ts @@ -3,19 +3,31 @@ import { createAicIncludedCreditsAllocator, type AicIncludedCreditsOverrides } f import { parseTokenUsageHeader, parseNormalizedTokenUsageRecord, + parseTokenUsageRecord, + validateSupportedReportRecord, validateHeader, type TokenUsageHeader, type TokenUsageRecord, } from './parser' import { streamLines, type StreamProgress } from './streamer' -async function validateFileHeader(file: File): Promise { +async function validateFileFormat(file: File): Promise { + let header: TokenUsageHeader | null = null + for await (const line of streamLines(file)) { const trimmed = line.trimEnd() - if (trimmed) { - validateHeader(parseTokenUsageHeader(trimmed)) - return + if (!trimmed) { + continue } + + if (!header) { + header = parseTokenUsageHeader(trimmed) + validateHeader(header) + continue + } + + validateSupportedReportRecord(header, parseTokenUsageRecord(trimmed, header)) + return } } @@ -67,7 +79,7 @@ export async function runPipeline( options?: PipelineOptions, ): Promise { const { includedCreditsOverrides = {}, progressResolution = 500, onProgress } = options ?? {} - await validateFileHeader(file) + await validateFileFormat(file) let lastProgressStage: PipelineProgress['stage'] | null = null let lastProgressPercent = -1 let lastProgressTimestamp = 0 From 5f173331951418287e2b635a4b87ab7e183e98b2 Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Sat, 30 May 2026 23:34:20 +0200 Subject: [PATCH 2/2] fix: clarify supported report uploads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/UploadPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/UploadPage.tsx b/src/components/UploadPage.tsx index c444f3b..773f556 100644 --- a/src/components/UploadPage.tsx +++ b/src/components/UploadPage.tsx @@ -117,12 +117,12 @@ export function UploadPage({

Drop your CSV here or click to browse

-

premiumRequestUsageReport_*.csv

+

April or May PRU vs usage-based billing CSV

)} -

Accepted: .csv files from the Premium Request Usage report

+

Accepted: April and May PRU vs usage-based billing reports