Skip to content

Commit d5cfd70

Browse files
authored
Merge pull request #140 from github/asizikov/included-credit-policy-selection
Select included-credit policy from report metadata
2 parents 26fc1ef + 0e93440 commit d5cfd70

9 files changed

Lines changed: 254 additions & 26 deletions

src/pipeline/aggregators/reportContextAggregator.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Aggregator } from './base'
22
import type { TokenUsageHeader, TokenUsageRecord } from '../parser'
3+
import { isValidIsoDate } from '../isoDate'
34

45
export type ReportContextResult = {
56
startDate: string | null
@@ -49,19 +50,3 @@ export class ReportContextAggregator implements Aggregator<TokenUsageRecord, Rep
4950
}
5051
}
5152
}
52-
53-
function isValidIsoDate(value: string): boolean {
54-
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
55-
if (!match) return false
56-
57-
const year = Number(match[1])
58-
const month = Number(match[2])
59-
const day = Number(match[3])
60-
const normalized = new Date(Date.UTC(year, month - 1, day))
61-
62-
return (
63-
normalized.getUTCFullYear() === year
64-
&& normalized.getUTCMonth() === month - 1
65-
&& normalized.getUTCDate() === day
66-
)
67-
}

src/pipeline/aicIncludedCredits.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
22
import {
33
BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS,
44
BUSINESS_MONTHLY_QUOTA,
5+
calculateAicIncludedCreditsContext,
56
calculateAicIncludedCreditsPool,
67
calculateLicenseSummary,
78
ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS,
@@ -51,13 +52,41 @@ const HEADER = [
5152
'aic_quantity',
5253
'aic_gross_amount',
5354
].join(',')
55+
const NATIVE_AI_CREDITS_HEADER = [
56+
'date',
57+
'username',
58+
'product',
59+
'sku',
60+
'model',
61+
'quantity',
62+
'unit_type',
63+
'applied_cost_per_quantity',
64+
'gross_amount',
65+
'discount_amount',
66+
'net_amount',
67+
'total_monthly_quota',
68+
'organization',
69+
'cost_center_name',
70+
'aic_quantity',
71+
'aic_gross_amount',
72+
].join(',')
73+
const NATIVE_AI_CREDITS_REPORT_METADATA = {
74+
format: 'native-ai-credits',
75+
label: 'Native AI Credits report',
76+
supported: false,
77+
} as const
5478
const UNKNOWN_HIGH_MONTHLY_QUOTA = 2147483647
5579

5680
function createCsv(rows: string[][]): File {
5781
const body = [HEADER, ...rows.map((row) => row.join(','))].join('\n')
5882
return new File([body], 'usage.csv', { type: 'text/csv' })
5983
}
6084

85+
function createNativeCsv(rows: string[][]): File {
86+
const body = [NATIVE_AI_CREDITS_HEADER, ...rows.map((row) => row.join(','))].join('\n')
87+
return new File([body], 'usage.csv', { type: 'text/csv' })
88+
}
89+
6190
function createRecord(overrides: Partial<TokenUsageRecord> = {}): TokenUsageRecord {
6291
return {
6392
date: '2026-03-01',
@@ -325,6 +354,26 @@ describe('AIC included credit tiering and pool sizing', () => {
325354
)
326355
})
327356

357+
it('derives native report periods before selecting native included credit policies', async () => {
358+
const summerFile = createNativeCsv([
359+
['2026-08-31', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '10', 'ai-credits', '0.01', '0.10', '0', '0.10', '3900', 'example-org', 'Cost Center A', '10', '0.10'],
360+
])
361+
const standardFile = createNativeCsv([
362+
['2026-09-01', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '10', 'ai-credits', '0.01', '0.10', '0', '0.10', '3900', 'example-org', 'Cost Center A', '10', '0.10'],
363+
])
364+
365+
await expect(calculateAicIncludedCreditsContext(summerFile, {}, {
366+
reportMetadata: NATIVE_AI_CREDITS_REPORT_METADATA,
367+
})).resolves.toMatchObject({
368+
organizationIncludedCreditsPool: 7000,
369+
})
370+
await expect(calculateAicIncludedCreditsContext(standardFile, {}, {
371+
reportMetadata: NATIVE_AI_CREDITS_REPORT_METADATA,
372+
})).resolves.toMatchObject({
373+
organizationIncludedCreditsPool: 3900,
374+
})
375+
})
376+
328377
it('uses the maximum quota seen for the same user before applying individual-plan classification', async () => {
329378
const file = createCsv([
330379
['2026-03-01', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '10', 'ai-credits', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cats', '10', '0.10'],

src/pipeline/aicIncludedCredits.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ import {
77
} from './parser'
88
import {
99
TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY,
10+
resolveIncludedCreditsPolicy,
1011
type IncludedCreditsPolicy,
1112
type OrganizationIncludedCreditTier,
1213
type PlanIdentity,
14+
type ReportPeriod,
1315
} from './includedCreditsPolicy'
16+
import { isValidIsoDate } from './isoDate'
1417
import { streamLines, type StreamProgress } from './streamer'
18+
import type { ReportFormatMetadata } from './reportAdapters'
1519

1620
type IndividualPlan = 'pro-student' | 'pro-plus'
1721

@@ -87,6 +91,7 @@ export type LicenseSummary = {
8791
export interface AicIncludedCreditsProgressOptions {
8892
onProgress?: (progress: StreamProgress) => void
8993
includedCreditsPolicy?: IncludedCreditsPolicy
94+
reportMetadata?: ReportFormatMetadata
9095
}
9196

9297
type ReportScopeUser = {
@@ -224,6 +229,31 @@ export function getIndividualMonthlyAicIncludedCredits(
224229
return findIndividualIncludedCreditsPlan(totalMonthlyQuota, reportPlanScope)?.monthlyIncludedCredits ?? 0
225230
}
226231

232+
function includeDateInReportPeriod(reportPeriod: ReportPeriod, rawDate: string): ReportPeriod {
233+
const date = rawDate.trim()
234+
if (!isValidIsoDate(date)) return reportPeriod
235+
236+
return {
237+
startDate: !reportPeriod.startDate || date < reportPeriod.startDate ? date : reportPeriod.startDate,
238+
endDate: !reportPeriod.endDate || date > reportPeriod.endDate ? date : reportPeriod.endDate,
239+
}
240+
}
241+
242+
function resolvePolicyForContext(
243+
options: AicIncludedCreditsProgressOptions | undefined,
244+
reportPeriod: ReportPeriod,
245+
): IncludedCreditsPolicy {
246+
if (options?.includedCreditsPolicy) {
247+
return options.includedCreditsPolicy
248+
}
249+
250+
if (options?.reportMetadata) {
251+
return resolveIncludedCreditsPolicy(options.reportMetadata, reportPeriod)
252+
}
253+
254+
return TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY
255+
}
256+
227257
export function calculateLicenseSummary(
228258
users: Array<{ totalMonthlyQuota: number } & ReportScopeUser>,
229259
policy: IncludedCreditsPolicy = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY,
@@ -274,10 +304,10 @@ export async function calculateAicIncludedCreditsContext(
274304
overrides: AicIncludedCreditsOverrides = {},
275305
options?: AicIncludedCreditsProgressOptions,
276306
): Promise<AicIncludedCreditsContext> {
277-
const includedCreditsPolicy = options?.includedCreditsPolicy ?? TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY
278307
let header: TokenUsageHeader | null = null
279-
const quotasByUser = new Map<string, number>()
308+
const quotaCandidatesByUser = new Map<string, Set<number>>()
280309
let hasOrganizationContext = false
310+
let reportPeriod: ReportPeriod = {}
281311

282312
for await (const line of streamLines(file, options)) {
283313
const trimmed = line.trimEnd()
@@ -291,22 +321,30 @@ export async function calculateAicIncludedCreditsContext(
291321
const record = parseNormalizedTokenUsageRecord(trimmed, header)
292322
if (!record) continue
293323

324+
reportPeriod = includeDateInReportPeriod(reportPeriod, record.date)
325+
294326
const username = record.username.trim()
295327
if (!username) continue
296328

297329
if (record.organization.trim() || (record.cost_center_name?.trim() ?? '')) {
298330
hasOrganizationContext = true
299331
}
300332

301-
const currentQuota = quotasByUser.get(username) ?? 0
302-
quotasByUser.set(username, selectKnownMonthlyQuota(
303-
currentQuota,
304-
record.total_monthly_quota,
305-
includedCreditsPolicy,
306-
))
333+
const quotaCandidates = quotaCandidatesByUser.get(username) ?? new Set<number>()
334+
quotaCandidates.add(record.total_monthly_quota)
335+
quotaCandidatesByUser.set(username, quotaCandidates)
307336
}
308337

309-
const reportPlanScope = inferReportPlanScope(quotasByUser.size, hasOrganizationContext)
338+
const includedCreditsPolicy = resolvePolicyForContext(options, reportPeriod)
339+
const quotasByUser = new Map<string, number>()
340+
quotaCandidatesByUser.forEach((quotaCandidates, username) => {
341+
quotasByUser.set(username, Array.from(quotaCandidates).reduce(
342+
(currentQuota, candidateQuota) => selectKnownMonthlyQuota(currentQuota, candidateQuota, includedCreditsPolicy),
343+
0,
344+
))
345+
})
346+
347+
const reportPlanScope = inferReportPlanScope(quotaCandidatesByUser.size, hasOrganizationContext)
310348
if (reportPlanScope === 'individual') {
311349
const quota = quotasByUser.values().next().value ?? 0
312350
return {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import type { ReportFormatMetadata } from './reportAdapters'
4+
import {
5+
NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY,
6+
NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY,
7+
TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY,
8+
resolveIncludedCreditsPolicy,
9+
} from './includedCreditsPolicy'
10+
11+
const TRANSITION_PERIOD_REPORT_METADATA = {
12+
format: 'transition-period-billing-preview',
13+
label: 'Transition Period Billing Preview report',
14+
supported: true,
15+
} satisfies ReportFormatMetadata
16+
17+
const NATIVE_AI_CREDITS_REPORT_METADATA = {
18+
format: 'native-ai-credits',
19+
label: 'Native AI Credits report',
20+
supported: false,
21+
} satisfies ReportFormatMetadata
22+
23+
describe('resolveIncludedCreditsPolicy', () => {
24+
it('returns the transition-period billing preview policy for transition reports', () => {
25+
expect(resolveIncludedCreditsPolicy(TRANSITION_PERIOD_REPORT_METADATA)).toBe(
26+
TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY,
27+
)
28+
expect(resolveIncludedCreditsPolicy('transition-period-billing-preview', {
29+
startDate: '2026-09-01',
30+
endDate: '2026-09-30',
31+
})).toBe(TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY)
32+
})
33+
34+
it('returns the native AI Credits summer promo policy for native report periods before September 2026', () => {
35+
expect(resolveIncludedCreditsPolicy(NATIVE_AI_CREDITS_REPORT_METADATA, {
36+
startDate: '2026-06-01',
37+
endDate: '2026-06-30',
38+
})).toBe(NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)
39+
})
40+
41+
it('uses period start to keep native report periods starting on 2026-08-31 in the summer promo policy', () => {
42+
expect(resolveIncludedCreditsPolicy(NATIVE_AI_CREDITS_REPORT_METADATA, {
43+
startDate: '2026-08-31',
44+
endDate: '2026-09-30',
45+
})).toBe(NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)
46+
})
47+
48+
it('returns the native AI Credits standard policy for native report periods starting on 2026-09-01 onward', () => {
49+
expect(resolveIncludedCreditsPolicy(NATIVE_AI_CREDITS_REPORT_METADATA, {
50+
startDate: '2026-09-01',
51+
endDate: '2026-09-30',
52+
})).toBe(NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY)
53+
expect(resolveIncludedCreditsPolicy(NATIVE_AI_CREDITS_REPORT_METADATA, {
54+
startDate: '2026-10-01',
55+
endDate: '2026-10-31',
56+
})).toBe(NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY)
57+
})
58+
59+
it('defaults native AI Credits reports without a valid period start to the standard policy', () => {
60+
expect(resolveIncludedCreditsPolicy(NATIVE_AI_CREDITS_REPORT_METADATA)).toBe(
61+
NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY,
62+
)
63+
expect(resolveIncludedCreditsPolicy(NATIVE_AI_CREDITS_REPORT_METADATA, {
64+
endDate: '2026-08-31',
65+
})).toBe(NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY)
66+
expect(resolveIncludedCreditsPolicy(NATIVE_AI_CREDITS_REPORT_METADATA, {
67+
startDate: '8/31/26',
68+
endDate: '2026-08-31',
69+
})).toBe(NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY)
70+
})
71+
})

src/pipeline/includedCreditsPolicy.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { ReportFormat, ReportFormatMetadata } from './reportAdapters'
2+
import { isValidIsoDate } from './isoDate'
3+
14
export type QuotaUnit = 'pru' | 'aic'
25
export type OrganizationIncludedCreditTier = 'business' | 'enterprise'
36

@@ -27,8 +30,14 @@ export type IncludedCreditsPolicy = {
2730
readonly organizationPlans: OrganizationIncludedCreditPlans
2831
}
2932

33+
export type ReportPeriod = {
34+
readonly startDate?: string | null
35+
readonly endDate?: string | null
36+
}
37+
3038
const COPILOT_BUSINESS_LABEL = 'Copilot Business'
3139
const COPILOT_ENTERPRISE_LABEL = 'Copilot Enterprise'
40+
const NATIVE_AI_CREDITS_STANDARD_POLICY_START_DATE = '2026-09-01'
3241

3342
export const TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY = {
3443
id: 'transition-period-billing-preview',
@@ -101,3 +110,30 @@ export const NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY = {
101110
},
102111
},
103112
} as const satisfies IncludedCreditsPolicy
113+
114+
function getReportFormat(reportMetadataOrFormat: ReportFormat | ReportFormatMetadata): ReportFormat {
115+
return typeof reportMetadataOrFormat === 'string'
116+
? reportMetadataOrFormat
117+
: reportMetadataOrFormat.format
118+
}
119+
120+
function isBeforeIsoDate(value: string | null | undefined, boundary: string): boolean {
121+
if (!value || !isValidIsoDate(value)) return false
122+
123+
return value < boundary
124+
}
125+
126+
export function resolveIncludedCreditsPolicy(
127+
reportMetadataOrFormat: ReportFormat | ReportFormatMetadata,
128+
reportPeriod: ReportPeriod = {},
129+
): IncludedCreditsPolicy {
130+
if (getReportFormat(reportMetadataOrFormat) === 'transition-period-billing-preview') {
131+
return TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY
132+
}
133+
134+
if (isBeforeIsoDate(reportPeriod.startDate, NATIVE_AI_CREDITS_STANDARD_POLICY_START_DATE)) {
135+
return NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY
136+
}
137+
138+
return NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY
139+
}

src/pipeline/isoDate.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { isValidIsoDate } from './isoDate'
4+
5+
describe('isValidIsoDate', () => {
6+
it('accepts only real YYYY-MM-DD calendar dates', () => {
7+
expect(isValidIsoDate('2026-03-01')).toBe(true)
8+
expect(isValidIsoDate('2024-02-29')).toBe(true)
9+
expect(isValidIsoDate('2026-02-30')).toBe(false)
10+
expect(isValidIsoDate('2026-13-01')).toBe(false)
11+
expect(isValidIsoDate('03/01/2026')).toBe(false)
12+
expect(isValidIsoDate(' 2026-03-01 ')).toBe(false)
13+
})
14+
})

src/pipeline/isoDate.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
2+
3+
export function isValidIsoDate(value: string): boolean {
4+
const match = ISO_DATE_PATTERN.exec(value)
5+
if (!match) return false
6+
7+
const year = Number(match[1])
8+
const month = Number(match[2])
9+
const day = Number(match[3])
10+
const normalized = new Date(Date.UTC(year, month - 1, day))
11+
12+
return (
13+
normalized.getUTCFullYear() === year
14+
&& normalized.getUTCMonth() === month - 1
15+
&& normalized.getUTCDate() === day
16+
)
17+
}

0 commit comments

Comments
 (0)