Skip to content

Commit 2de810c

Browse files
authored
Merge pull request #133 from github/asizikov/report-metadata-flow
Thread report metadata through pipeline
2 parents fdad591 + 7412559 commit 2de810c

4 files changed

Lines changed: 54 additions & 11 deletions

File tree

src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
} from './pipeline/aicIncludedCredits'
3535
import { PRODUCT_BUDGET_COPILOT, PRODUCT_BUDGET_COPILOT_CLOUD_AGENT, PRODUCT_BUDGET_SPARK } from './pipeline/productClassification'
3636
import { runPipeline } from './pipeline/runPipeline'
37+
import type { ReportFormatMetadata } from './pipeline/reportAdapters'
3738
import { runBudgetSimulation, type BudgetSimulationResult } from './utils/budgetSimulation'
3839
import { EMPTY_BUDGET_VALUES, getDefaultBudgetValues, getUserSpendSegmentsByUsername, type BudgetField, type BudgetValues } from './utils/costManagementBudgets'
3940
import { calculateIndividualPlanUpgradeRecommendation, getIndividualLicenseMonthlyCost } from './utils/individualPlanUpgrade'
@@ -49,6 +50,7 @@ const ENTERPRISE_LICENSE_MONTHLY_COST = 39
4950
function App() {
5051
const [status, setStatus] = useState<Status>('idle')
5152
const [quickStats, setQuickStats] = useState<QuickStatsResult | null>(null)
53+
const [reportMetadata, setReportMetadata] = useState<ReportFormatMetadata | null>(null)
5254
const [reportContext, setReportContext] = useState<ReportContextResult | null>(null)
5355
const [error, setError] = useState<string | null>(null)
5456
const [fileName, setFileName] = useState<string | null>(null)
@@ -79,6 +81,7 @@ function App() {
7981

8082
const applyProcessedData = useCallback(({
8183
quickStats,
84+
reportMetadata,
8285
reportContext,
8386
dailyUsageData,
8487
modelUsage,
@@ -88,6 +91,7 @@ function App() {
8891
userUsage,
8992
}: {
9093
quickStats: QuickStatsResult
94+
reportMetadata: ReportFormatMetadata
9195
reportContext: ReportContextResult
9296
dailyUsageData: DailyUsageData[]
9397
modelUsage: ModelUsageResult
@@ -97,6 +101,7 @@ function App() {
97101
userUsage: UserUsageResult
98102
}) => {
99103
setQuickStats(quickStats)
104+
setReportMetadata(reportMetadata)
100105
setReportContext(reportContext)
101106
setDailyUsageData(dailyUsageData)
102107
setModelUsage(modelUsage)
@@ -140,6 +145,7 @@ function App() {
140145
...statsAggregator.result(),
141146
lineCount: pipelineResult.reportRowCount,
142147
},
148+
reportMetadata: pipelineResult.reportMetadata,
143149
reportContext: contextAggregator.result(),
144150
dailyUsageData: dailyAggregator.result().dailyData,
145151
modelUsage: modelAggregator.result(),
@@ -168,6 +174,7 @@ function App() {
168174
setStatus(status)
169175
setError(null)
170176
setQuickStats(null)
177+
setReportMetadata(null)
171178
setReportContext(null)
172179
setDailyUsageData([])
173180
setUserUsage(null)
@@ -476,7 +483,7 @@ function App() {
476483
}
477484
}
478485

479-
const hasReport = status === 'done' && fileName !== null
486+
const hasReport = status === 'done' && fileName !== null && reportMetadata !== null
480487
const showSeatConfirmation = hasReport && seatConfirmationPending
481488
const rangeStart = reportContext?.startDate ?? null
482489
const rangeEnd = reportContext?.endDate ?? null

src/pipeline/reportAdapters.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,14 @@ const REPORT_ADAPTERS: Record<ReportFormat, UsageReportAdapter> = {
5454
'native-ai-credits': NATIVE_AI_CREDITS_REPORT_ADAPTER,
5555
}
5656

57-
export function validateUsageReportHeader(header: TokenUsageHeader): void {
58-
validateTokenUsageHeader(header)
57+
export function getDefaultSupportedUsageReportAdapter(): UsageReportAdapter {
58+
return TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER
59+
}
60+
61+
export function validateUsageReportHeader(header: TokenUsageHeader): UsageReportAdapter {
62+
const adapter = getDefaultSupportedUsageReportAdapter()
63+
adapter.validateHeader(header)
64+
return adapter
5965
}
6066

6167
export function detectReportFormat(header: TokenUsageHeader, firstRecord: TokenUsageRecord): ReportFormat {

src/pipeline/runPipeline.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ const NATIVE_AI_CREDITS_HEADER = [
4343
'aic_gross_amount',
4444
].join(',')
4545

46+
const TRANSITION_PERIOD_REPORT_METADATA = {
47+
format: 'transition-period-billing-preview',
48+
label: 'Transition Period Billing Preview report',
49+
supported: true,
50+
}
51+
4652
function createCsv(rows: string[][], header = HEADER): File {
4753
const body = [header, ...rows.map((row) => row.join(','))].join('\n')
4854
return new File([body], 'usage.csv', { type: 'text/csv' })
@@ -65,10 +71,19 @@ class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord
6571
}
6672

6773
describe('runPipeline', () => {
68-
it('accepts a valid header-only report', async () => {
74+
it('rejects reports without a header row', async () => {
75+
const aggregator = new CaptureAggregator()
76+
const file = new File(['\n\n'], 'usage.csv', { type: 'text/csv' })
77+
78+
await expect(runPipeline(file, [aggregator])).rejects.toThrow(InvalidReportError)
79+
expect(aggregator.result()).toEqual([])
80+
})
81+
82+
it('returns transition-period metadata for a valid header-only report', async () => {
6983
const aggregator = new CaptureAggregator()
7084

7185
await expect(runPipeline(createCsv([]), [aggregator])).resolves.toEqual({
86+
reportMetadata: TRANSITION_PERIOD_REPORT_METADATA,
7287
reportRowCount: 0,
7388
processedRowCount: 0,
7489
})
@@ -135,7 +150,7 @@ describe('runPipeline', () => {
135150
expect(aggregator.result()).toEqual([])
136151
})
137152

138-
it('filters and normalizes known normalization window rows before AIC allocation', async () => {
153+
it('returns transition-period metadata while processing supported reports', async () => {
139154
const file = createCsv([
140155
['2026-04-25', 'mona', 'copilot', 'copilot_premium_request', 'GPT-5', '0', 'requests', '0.04', '0', '0', '0', 'False', '300', '', '', '0', '0'],
141156
['2026-04-25', 'mona', 'copilot', 'copilot_premium_request', 'GPT-5', '10', 'requests', '0.04', '0.40', '0', '0.40', 'False', '0', '', '', '100', '1.00'],
@@ -155,6 +170,7 @@ describe('runPipeline', () => {
155170
}),
156171
])
157172
expect(result).toEqual({
173+
reportMetadata: TRANSITION_PERIOD_REPORT_METADATA,
158174
reportRowCount: 2,
159175
processedRowCount: 1,
160176
})

src/pipeline/runPipeline.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import type { Aggregator } from './aggregators/base'
22
import { createAicIncludedCreditsAllocator, type AicIncludedCreditsOverrides } from './aicIncludedCredits'
33
import {
4+
InvalidReportError,
45
parseTokenUsageHeader,
56
parseNormalizedTokenUsageRecord,
67
parseTokenUsageRecord,
78
type TokenUsageHeader,
89
type TokenUsageRecord,
910
} from './parser'
10-
import { validateUsageReportFirstRecord, validateUsageReportHeader } from './reportAdapters'
11+
import {
12+
validateUsageReportFirstRecord,
13+
validateUsageReportHeader,
14+
type ReportFormatMetadata,
15+
type UsageReportAdapter,
16+
} from './reportAdapters'
1117
import { streamLines, type StreamProgress } from './streamer'
1218

13-
async function validateFileFormat(file: File): Promise<void> {
19+
async function validateFileFormat(file: File): Promise<ReportFormatMetadata> {
1420
let header: TokenUsageHeader | null = null
21+
let selectedAdapter: UsageReportAdapter | null = null
1522

1623
for await (const line of streamLines(file)) {
1724
const trimmed = line.trimEnd()
@@ -21,13 +28,18 @@ async function validateFileFormat(file: File): Promise<void> {
2128

2229
if (!header) {
2330
header = parseTokenUsageHeader(trimmed)
24-
validateUsageReportHeader(header)
31+
selectedAdapter = validateUsageReportHeader(header)
2532
continue
2633
}
2734

28-
validateUsageReportFirstRecord(header, parseTokenUsageRecord(trimmed, header))
29-
return
35+
return validateUsageReportFirstRecord(header, parseTokenUsageRecord(trimmed, header)).metadata
36+
}
37+
38+
if (!selectedAdapter) {
39+
throw new InvalidReportError()
3040
}
41+
42+
return selectedAdapter.metadata
3143
}
3244

3345
export interface PipelineProgress {
@@ -45,6 +57,7 @@ export interface PipelineOptions {
4557
}
4658

4759
export interface PipelineResult {
60+
reportMetadata: ReportFormatMetadata
4861
reportRowCount: number
4962
processedRowCount: number
5063
}
@@ -78,7 +91,7 @@ export async function runPipeline(
7891
options?: PipelineOptions,
7992
): Promise<PipelineResult> {
8093
const { includedCreditsOverrides = {}, progressResolution = 500, onProgress } = options ?? {}
81-
await validateFileFormat(file)
94+
const reportMetadata = await validateFileFormat(file)
8295
let lastProgressStage: PipelineProgress['stage'] | null = null
8396
let lastProgressPercent = -1
8497
let lastProgressTimestamp = 0
@@ -179,6 +192,7 @@ export async function runPipeline(
179192
}
180193

181194
return {
195+
reportMetadata,
182196
reportRowCount,
183197
processedRowCount: rowIndex,
184198
}

0 commit comments

Comments
 (0)