Skip to content

Commit 9c55687

Browse files
asizikovCopilot
andcommitted
test: cover parser and AIC entitlement logic
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fac2b47 commit 9c55687

5 files changed

Lines changed: 715 additions & 43 deletions

File tree

docs/report-format.md

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,12 @@ Source examples: files in `examples/`. Some existing sample exports still use th
2121
| `total_monthly_quota` | number | Monthly quota for the user or plan applicable to the row. |
2222
| `organization` | string | Organization slug associated with the usage. |
2323
| `cost_center_name` | string | Optional cost center or tagging field. |
24-
## Optional AI Credit columns
25-
26-
| Column | Status | Notes |
27-
| --- | --- | --- |
2824
| `aic_quantity` | Present in newer previews | Same usage converted to AI Credits. |
2925
| `aic_gross_amount` | Present in newer previews | Used for AI Credit gross cost when present. |
30-
| `total_input_tokens` | Legacy / optional | Older token-oriented export field; ignored by the app. |
31-
| `total_output_tokens` | Legacy / optional | Older token-oriented export field; ignored by the app. |
32-
| `total_cache_creation_tokens` | Legacy / optional | Older token-oriented export field; ignored by the app. |
33-
| `total_cache_read_tokens` | Legacy / optional | Older token-oriented export field; ignored by the app. |
3426

3527
## Notes
3628
- Header row is single-lined; subsequent rows are usage records. Values are comma-separated, double-quoted where needed.
3729
- Monetary fields are decimals. `aic_quantity` may also be fractional.
3830
- The app streams the file, treats the first row as the header, and counts subsequent data rows without loading the entire file into memory.
39-
- Current calculations support both row types: on `requests` rows, `quantity` and `applied_cost_per_quantity` describe PRU usage while `aic_quantity` and `aic_gross_amount` remain reference/comparison values and stay `0` when those AI Credit fields are absent; on `ai-units` rows, Premium Requests and generic PRU gross/discount/net totals are counted as `0`, while AI Credits and AI Credit gross cost are taken from the AI-specific fields (`aic_quantity` / `aic_gross_amount`, with fallback to `quantity` / `gross_amount` only when those AI-specific fields are absent).
40-
- Reports without `aic_quantity` still load, but AI Credit summaries will display `0`.
31+
- Current calculations support both row types: on `requests` rows, `quantity` and `applied_cost_per_quantity` describe PRU usage while `aic_quantity` and `aic_gross_amount` remain reference/comparison values; on `ai-units` rows, Premium Requests and generic PRU gross/discount/net totals are counted as `0`, while AI Credits and AI Credit gross cost are taken from the AI-specific fields (`aic_quantity` / `aic_gross_amount`), with fallback to `quantity` / `gross_amount` only when those AI-specific fields are present but blank.
32+
- The current app expects the current CSV format, including `aic_quantity` and `aic_gross_amount`.
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
BUSINESS_MONTHLY_AIC_ENTITLEMENTS,
4+
BUSINESS_MONTHLY_QUOTA,
5+
calculateAicEntitlementPool,
6+
calculateLicenseSummary,
7+
ENTERPRISE_MONTHLY_AIC_ENTITLEMENTS,
8+
ENTERPRISE_MONTHLY_QUOTA,
9+
getAicEntitlementTier,
10+
getMonthlyAicEntitlements,
11+
PooledAicEntitlementAllocator,
12+
} from './aicEntitlements'
13+
import { runPipeline } from './runPipeline'
14+
import type { Aggregator } from './aggregators/base'
15+
import type { TokenUsageHeader, TokenUsageRecord } from './parser'
16+
17+
const HEADER = [
18+
'date',
19+
'username',
20+
'product',
21+
'sku',
22+
'model',
23+
'quantity',
24+
'unit_type',
25+
'applied_cost_per_quantity',
26+
'gross_amount',
27+
'discount_amount',
28+
'net_amount',
29+
'exceeds_quota',
30+
'total_monthly_quota',
31+
'organization',
32+
'cost_center_name',
33+
'aic_quantity',
34+
'aic_gross_amount',
35+
].join(',')
36+
37+
function createCsv(rows: string[][]): File {
38+
const body = [HEADER, ...rows.map((row) => row.join(','))].join('\n')
39+
return new File([body], 'usage.csv', { type: 'text/csv' })
40+
}
41+
42+
function createRecord(overrides: Partial<TokenUsageRecord> = {}): TokenUsageRecord {
43+
return {
44+
date: '2026-03-01',
45+
username: 'mona',
46+
product: 'copilot',
47+
sku: 'copilot_ai_unit',
48+
model: 'GPT-5',
49+
quantity: 10,
50+
unit_type: 'ai-units',
51+
applied_cost_per_quantity: 0.01,
52+
gross_amount: 0.1,
53+
discount_amount: 0,
54+
net_amount: 0.1,
55+
exceeds_quota: false,
56+
total_monthly_quota: 300,
57+
organization: 'octo',
58+
cost_center_name: 'Cats',
59+
aic_quantity: 10,
60+
aic_gross_amount: 0.1,
61+
aic_net_amount: 0.1,
62+
has_aic_quantity: true,
63+
has_aic_gross_amount: true,
64+
...overrides,
65+
}
66+
}
67+
68+
class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord[], TokenUsageHeader> {
69+
private readonly records: TokenUsageRecord[] = []
70+
71+
onHeader(): void {
72+
// no-op
73+
}
74+
75+
accumulate(record: TokenUsageRecord): void {
76+
this.records.push({ ...record })
77+
}
78+
79+
result(): TokenUsageRecord[] {
80+
return this.records
81+
}
82+
}
83+
84+
describe('AIC entitlement tiering and pool sizing', () => {
85+
it('classifies 299 as null', () => {
86+
expect(getAicEntitlementTier(299)).toBeNull()
87+
})
88+
89+
it('classifies 300 as business', () => {
90+
expect(getAicEntitlementTier(BUSINESS_MONTHLY_QUOTA)).toBe('business')
91+
})
92+
93+
it('classifies 999 as null', () => {
94+
expect(getAicEntitlementTier(999)).toBeNull()
95+
})
96+
97+
it('classifies 1000 as enterprise', () => {
98+
expect(getAicEntitlementTier(ENTERPRISE_MONTHLY_QUOTA)).toBe('enterprise')
99+
})
100+
101+
it('returns 0 entitlements for 299', () => {
102+
expect(getMonthlyAicEntitlements(299)).toBe(0)
103+
})
104+
105+
it('returns 3000 entitlements for 300', () => {
106+
expect(getMonthlyAicEntitlements(BUSINESS_MONTHLY_QUOTA)).toBe(BUSINESS_MONTHLY_AIC_ENTITLEMENTS)
107+
})
108+
109+
it('returns 7000 entitlements for 1000', () => {
110+
expect(getMonthlyAicEntitlements(ENTERPRISE_MONTHLY_QUOTA)).toBe(ENTERPRISE_MONTHLY_AIC_ENTITLEMENTS)
111+
})
112+
113+
it('sizes the pool for one business user', async () => {
114+
const file = createCsv([
115+
['2026-03-01', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cats', '10', '0.10'],
116+
])
117+
118+
await expect(calculateAicEntitlementPool(file)).resolves.toBe(BUSINESS_MONTHLY_AIC_ENTITLEMENTS)
119+
})
120+
121+
it('sizes the pool for one enterprise user', async () => {
122+
const file = createCsv([
123+
['2026-03-01', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '1000', 'octo', 'Cats', '10', '0.10'],
124+
])
125+
126+
await expect(calculateAicEntitlementPool(file)).resolves.toBe(ENTERPRISE_MONTHLY_AIC_ENTITLEMENTS)
127+
})
128+
129+
it('sums business and enterprise users in the same pool', async () => {
130+
const file = createCsv([
131+
['2026-03-01', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cats', '10', '0.10'],
132+
['2026-03-01', 'hubot', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '1000', 'octo', 'Cats', '10', '0.10'],
133+
])
134+
135+
await expect(calculateAicEntitlementPool(file)).resolves.toBe(
136+
BUSINESS_MONTHLY_AIC_ENTITLEMENTS + ENTERPRISE_MONTHLY_AIC_ENTITLEMENTS,
137+
)
138+
})
139+
140+
it('uses the maximum quota seen for the same user', async () => {
141+
const file = createCsv([
142+
['2026-03-01', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cats', '10', '0.10'],
143+
['2026-03-02', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '1000', 'octo', 'Cats', '10', '0.10'],
144+
])
145+
146+
await expect(calculateAicEntitlementPool(file)).resolves.toBe(ENTERPRISE_MONTHLY_AIC_ENTITLEMENTS)
147+
})
148+
149+
it('trims usernames before contributing to the pool', async () => {
150+
const file = createCsv([
151+
['2026-03-01', ' mona ', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cats', '10', '0.10'],
152+
['2026-03-02', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '1000', 'octo', 'Cats', '10', '0.10'],
153+
])
154+
155+
await expect(calculateAicEntitlementPool(file)).resolves.toBe(ENTERPRISE_MONTHLY_AIC_ENTITLEMENTS)
156+
})
157+
158+
it('treats case-distinct usernames as separate users', async () => {
159+
const file = createCsv([
160+
['2026-03-01', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cats', '10', '0.10'],
161+
['2026-03-02', 'MONA', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '1000', 'octo', 'Cats', '10', '0.10'],
162+
])
163+
164+
await expect(calculateAicEntitlementPool(file)).resolves.toBe(
165+
BUSINESS_MONTHLY_AIC_ENTITLEMENTS + ENTERPRISE_MONTHLY_AIC_ENTITLEMENTS,
166+
)
167+
})
168+
169+
it('ignores empty usernames when sizing the pool', async () => {
170+
const file = createCsv([
171+
['2026-03-01', '', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '1000', 'octo', 'Cats', '10', '0.10'],
172+
['2026-03-02', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cats', '10', '0.10'],
173+
])
174+
175+
await expect(calculateAicEntitlementPool(file)).resolves.toBe(BUSINESS_MONTHLY_AIC_ENTITLEMENTS)
176+
})
177+
178+
it('groups users into Copilot Business and Copilot Enterprise license summary rows', () => {
179+
const summary = calculateLicenseSummary([
180+
{ totalMonthlyQuota: 300 },
181+
{ totalMonthlyQuota: 300 },
182+
{ totalMonthlyQuota: 1000 },
183+
{ totalMonthlyQuota: 999 },
184+
])
185+
186+
expect(summary).toEqual({
187+
rows: [
188+
{ label: 'Copilot Business', users: 2, includedAic: 6000 },
189+
{ label: 'Copilot Enterprise', users: 1, includedAic: 7000 },
190+
],
191+
totalUsers: 3,
192+
totalIncludedAic: 13000,
193+
})
194+
})
195+
})
196+
197+
describe('pooled AIC allocation and derived AIC discounts', () => {
198+
it('sets aic_net_amount to 0 when the row is fully covered', () => {
199+
const allocator = new PooledAicEntitlementAllocator(10)
200+
const record = createRecord({ aic_quantity: 10, aic_gross_amount: 0.1, aic_net_amount: 0.1 })
201+
202+
const allocated = allocator.apply(record)
203+
204+
expect(allocated.aic_net_amount).toBe(0)
205+
})
206+
207+
it('leaves aic_net_amount equal to AIC gross when the pool is 0', () => {
208+
const allocator = new PooledAicEntitlementAllocator(0)
209+
const record = createRecord({ aic_quantity: 10, aic_gross_amount: 0.1, aic_net_amount: 0.1 })
210+
211+
const allocated = allocator.apply(record)
212+
213+
expect(allocated.aic_net_amount).toBe(0.1)
214+
})
215+
216+
it('applies the uncovered ratio for partial coverage', () => {
217+
const allocator = new PooledAicEntitlementAllocator(4)
218+
const record = createRecord({ aic_quantity: 10, aic_gross_amount: 0.2, aic_net_amount: 0.2 })
219+
220+
const allocated = allocator.apply(record)
221+
222+
expect(allocated.aic_net_amount).toBeCloseTo(0.12)
223+
})
224+
225+
it('decreases the remaining balance by covered AIC quantity only', () => {
226+
const allocator = new PooledAicEntitlementAllocator(4)
227+
228+
allocator.apply(createRecord({ aic_quantity: 10, aic_gross_amount: 0.2, aic_net_amount: 0.2 }))
229+
230+
expect(allocator.remaining()).toBe(0)
231+
})
232+
233+
it('leaves aic_net_amount unchanged when aicQuantity is 0 or less', () => {
234+
const allocator = new PooledAicEntitlementAllocator(10)
235+
const record = createRecord({ aic_quantity: 0, aic_gross_amount: 0.1, aic_net_amount: 0.1 })
236+
237+
const allocated = allocator.apply(record)
238+
239+
expect(allocated.aic_net_amount).toBe(0.1)
240+
expect(allocator.remaining()).toBe(10)
241+
})
242+
243+
it('leaves aic_net_amount unchanged when aicGrossAmount is 0 or less', () => {
244+
const allocator = new PooledAicEntitlementAllocator(10)
245+
const record = createRecord({ aic_quantity: 10, aic_gross_amount: 0, aic_net_amount: 0 })
246+
247+
const allocated = allocator.apply(record)
248+
249+
expect(allocated.aic_net_amount).toBe(0)
250+
expect(allocator.remaining()).toBe(10)
251+
})
252+
253+
it('exhausts the pool across multiple rows', () => {
254+
const allocator = new PooledAicEntitlementAllocator(10)
255+
const first = allocator.apply(createRecord({ aic_quantity: 6, aic_gross_amount: 0.06, aic_net_amount: 0.06 }))
256+
const second = allocator.apply(createRecord({ aic_quantity: 6, aic_gross_amount: 0.06, aic_net_amount: 0.06 }))
257+
258+
expect(first.aic_net_amount).toBe(0)
259+
expect(second.aic_net_amount).toBeCloseTo(0.02)
260+
expect(allocator.remaining()).toBe(0)
261+
})
262+
263+
it('produces different per-row net amounts when row order changes', () => {
264+
const firstAllocator = new PooledAicEntitlementAllocator(10)
265+
const firstSmall = firstAllocator.apply(createRecord({ aic_quantity: 4, aic_gross_amount: 0.04, aic_net_amount: 0.04 }))
266+
const firstLarge = firstAllocator.apply(createRecord({ aic_quantity: 10, aic_gross_amount: 0.1, aic_net_amount: 0.1 }))
267+
268+
const secondAllocator = new PooledAicEntitlementAllocator(10)
269+
const secondLarge = secondAllocator.apply(createRecord({ aic_quantity: 10, aic_gross_amount: 0.1, aic_net_amount: 0.1 }))
270+
const secondSmall = secondAllocator.apply(createRecord({ aic_quantity: 4, aic_gross_amount: 0.04, aic_net_amount: 0.04 }))
271+
272+
expect(firstSmall.aic_net_amount).toBe(0)
273+
expect(firstLarge.aic_net_amount).toBeCloseTo(0.04)
274+
expect(secondLarge.aic_net_amount).toBe(0)
275+
expect(secondSmall.aic_net_amount).toBe(0.04)
276+
})
277+
278+
it('preserves reasonable floating-point accuracy for fractional values', () => {
279+
const allocator = new PooledAicEntitlementAllocator(0.3)
280+
const record = createRecord({ aic_quantity: 0.5, aic_gross_amount: 0.05, aic_net_amount: 0.05 })
281+
282+
const allocated = allocator.apply(record)
283+
284+
expect(allocated.aic_net_amount).toBeCloseTo(0.02)
285+
})
286+
287+
it('applies pooled allocation before records reach aggregators in runPipeline', async () => {
288+
const file = createCsv([
289+
['2026-03-01', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '10', 'ai-units', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cats', '10', '0.10'],
290+
['2026-03-02', 'mona', 'copilot', 'copilot_ai_unit', 'GPT-5', '3005', 'ai-units', '0.01', '30.05', '0', '30.05', 'False', '300', 'octo', 'Cats', '3005', '30.05'],
291+
])
292+
const aggregator = new CaptureAggregator()
293+
294+
await runPipeline(file, [aggregator])
295+
296+
const records = aggregator.result()
297+
expect(records).toHaveLength(2)
298+
expect(records[0].aic_net_amount).toBe(0)
299+
expect(records[1].aic_net_amount).toBeCloseTo(0.15)
300+
})
301+
})

src/pipeline/aicEntitlements.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,21 @@ export const ENTERPRISE_MONTHLY_AIC_ENTITLEMENTS = 7000
1515

1616
export type AicEntitlementTier = 'business' | 'enterprise' | null
1717

18+
export type LicenseSummaryRow = {
19+
label: 'Copilot Business' | 'Copilot Enterprise'
20+
users: number
21+
includedAic: number
22+
}
23+
24+
export type LicenseSummary = {
25+
rows: LicenseSummaryRow[]
26+
totalUsers: number
27+
totalIncludedAic: number
28+
}
29+
1830
export function getAicEntitlementTier(totalMonthlyQuota: number): AicEntitlementTier {
19-
if (totalMonthlyQuota >= ENTERPRISE_MONTHLY_QUOTA) return 'enterprise'
20-
if (totalMonthlyQuota >= BUSINESS_MONTHLY_QUOTA) return 'business'
31+
if (totalMonthlyQuota === ENTERPRISE_MONTHLY_QUOTA) return 'enterprise'
32+
if (totalMonthlyQuota === BUSINESS_MONTHLY_QUOTA) return 'business'
2133
return null
2234
}
2335

@@ -28,6 +40,34 @@ export function getMonthlyAicEntitlements(totalMonthlyQuota: number): number {
2840
return 0
2941
}
3042

43+
export function calculateLicenseSummary(users: { totalMonthlyQuota: number }[]): LicenseSummary {
44+
const rows: LicenseSummaryRow[] = [
45+
{ label: 'Copilot Business', users: 0, includedAic: 0 },
46+
{ label: 'Copilot Enterprise', users: 0, includedAic: 0 },
47+
]
48+
49+
users.forEach((user) => {
50+
const tier = getAicEntitlementTier(user.totalMonthlyQuota)
51+
const includedAic = getMonthlyAicEntitlements(user.totalMonthlyQuota)
52+
53+
if (tier === 'business') {
54+
rows[0].users += 1
55+
rows[0].includedAic += includedAic
56+
}
57+
58+
if (tier === 'enterprise') {
59+
rows[1].users += 1
60+
rows[1].includedAic += includedAic
61+
}
62+
})
63+
64+
return {
65+
rows,
66+
totalUsers: rows.reduce((sum, row) => sum + row.users, 0),
67+
totalIncludedAic: rows.reduce((sum, row) => sum + row.includedAic, 0),
68+
}
69+
}
70+
3171
export async function calculateAicEntitlementPool(file: File): Promise<number> {
3272
let header: TokenUsageHeader | null = null
3373
const quotasByUser = new Map<string, number>()

0 commit comments

Comments
 (0)