feat: Copilot AI Credit plan math from total_nano_aiu

Spent credits are finite nanoAiu / 1e9. Token-priced costUSD does not
fill the Copilot bar. Thread nanoAiu through cachedCallToApiCall with
no cache-version bump.
This commit is contained in:
Aditya Vikram Singh 2026-08-23 19:12:04 +05:30
parent c9dab7deb6
commit 0c7d92a312
19 changed files with 609 additions and 32 deletions

View file

@ -3,6 +3,7 @@
## Unreleased
### Added
- **Copilot AI Credit plans.** `codeburn plan set copilot-pro` (1,500 credits / $15 equivalent, not the $10 sticker), `copilot-pro-plus` (7,000), `copilot-max` (20,000), and `custom --credits N --provider copilot`. Spend is `sum(finite nanoAiu) / 1e9`. Token-priced `costUSD` does not fill the credits bar. Claude / cursor / grok / custom-USD plans stay on `costUSD`. (#943)
- **`codeburn models --unpriced`.** The dashboard warns about models that price at $0 and points at `codeburn model-alias`, but the list itself was hard to get out of the TUI. This filters the plain-stdout `models` report to exactly those rows, reusing `findUnpricedModels` so local, free, aliased and price-overridden models are treated the same way the warning treats them, and defaulting that mode's min-cost to 0 so $0 rows are not pre-filtered away. Thanks @kocaemre. (#969)
- **`optimize` spots the same long block pasted at the start of many sessions.** The new `recurring-context` detector groups sessions by their opening block — normalized for whitespace and ANSI, hashed over the first 2 KB — and reports a block of at least 1.5 KB that opens 5 or more sessions, with the top three by tokens, their session counts and the project each is confined to. It is a habit, not an apply-able fix: CodeBurn will not move your own text into `CLAUDE.md` for you, so the finding asks Claude to give the block a permanent home (a `CLAUDE.md` rule, or a file read on demand) and hand back a one-line pointer to open sessions with instead. Savings count the repeats only, never the first paste, and are marked `estimated`: provider usage is counted per API call, where the pasted block is mixed in with the system prompt, tool schemas and `CLAUDE.md`, so the block is sized from its own bytes. Injected system reminders and slash-command wrappers are not pastes and are skipped, and neither is a prompt a program wrote — an SDK session or a subagent task — read from the entry's flags, which survive the parser's large-line path. The opening block comes from the session scan that already runs, so nothing extra is read from disk.
- **Applied fixes get re-measured on every `optimize` run, and told plainly whether they worked.** After `codeburn optimize --apply`, every still-applied fix comes back in an `Applied fixes` section on subsequent `codeburn optimize` runs, carrying the verdict `act report` already computes from the same reconciliation: `worked` (at least 70% of its window-scaled estimate realized), `partial` (something, but under that), `no-effect` (no measured reduction, printed with the exact `codeburn act undo <id>` that puts it back), or `measuring` for anything younger than the 3-day measurement window. The numbers are measured — provider-counted usage over the post-apply window — not re-estimated. `--apply` now says when the re-measure will happen, `--format json` gains `appliedFixes[]` (add-only), and the same section appears in the dashboard TUI and the desktop app. New `codeburn optimize --auto-revert` undoes the fixes that measured no reduction at all through the same code path as `codeburn act undo`; it never touches `partial` or still-measuring fixes, and never auto-reverts a `CLAUDE.md` rule (it prints the undo command instead), matching the `--yes` guardrail.

View file

@ -568,14 +568,16 @@ For categories that involve code edits, CodeBurn tracks file-aware retry cycles.
codeburn plan set claude-max # $200/month
codeburn plan set claude-pro # $20/month
codeburn plan set cursor-pro # $20/month
codeburn plan set copilot-pro # 1500 AI Credits ($15 equivalent)
codeburn plan set custom --monthly-usd 200 --provider codex # ChatGPT Pro-style custom plan
codeburn plan set custom --credits 20000 --provider copilot # org Copilot allotment
codeburn plan reset --provider codex # remove one provider plan
codeburn plan set none # disable plan view
codeburn plan # show configured plans
codeburn plan reset # remove plan config
```
Subscription tracking for Claude Pro, Claude Max, Cursor Pro, and custom provider plans. Plans are stored per provider, so you can track Claude and Codex/Cursor subscriptions at the same time; the dashboard shows one overage line per active provider plan. A legacy/custom `all` plan remains a single aggregate plan and is replaced when you add a provider-specific plan, avoiding double-counted overage rows. Existing single-plan config is still read as a fallback. Presets use publicly stated plan prices (as of April 2026); they do not model exact token allowances, because vendors do not publish precise consumer-plan limits.
Subscription tracking for Claude Pro, Claude Max, Cursor Pro, Copilot (AI credits), and custom provider plans. Plans are stored per provider, so you can track Claude and Codex/Cursor subscriptions at the same time; the dashboard shows one overage line per active provider plan. A legacy/custom `all` plan remains a single aggregate plan and is replaced when you add a provider-specific plan, avoiding double-counted overage rows. Existing single-plan config is still read as a fallback. USD presets use publicly stated plan prices (as of April 2026). Copilot presets use official individual AI-credit allotments (Pro 1,500 / Pro+ 7,000 / Max 20,000; fetched 2026-08-23) — not the $10 / $39 / $100 sticker prices — and spend is `total_nano_aiu / 1e9`, never token-priced USD.
### Currency

View file

@ -140,8 +140,9 @@ see the #927 ruling in `src/session-cache.ts`).
still re-derive its pruned rows.
- **Billing metadata.** Each row's `total_nano_aiu` and `request_multiplier`
are captured onto the cached call when the store's schema has them (older
stores parse identically without). Nothing prices or displays them yet —
billing-grade cost is upstream #890.
stores parse identically without). Plan math sums finite `nanoAiu` into
Copilot AI credits (`codeburn plan set copilot-pro`). Billing-grade cost
rewrite of every report is still upstream #890.
- **Sync.** `codeburn sync push` holds a copilot session until it has been
quiet for 24 hours. The reconciliation output is mutable (a residual shrinks
as rows land, a rollup is dropped once rows cover its leg, a row's pairing

View file

@ -4,12 +4,15 @@ import { homedir } from 'os'
import { randomBytes } from 'crypto'
import { PLAN_PROVIDERS } from './plans.js'
export type PlanId = 'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy' | 'custom' | 'none'
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'grok' | 'all'
export type PlanId = 'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy' | 'copilot-pro' | 'copilot-pro-plus' | 'copilot-max' | 'custom' | 'none'
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'grok' | 'copilot' | 'all'
export type Plan = {
id: PlanId
monthlyUsd: number
// Copilot plans budget in AI credits. monthlyUsd stays as credits × $0.01 so
// isActivePlan and add-only JSON keep working. Absent on USD plans.
monthlyCredits?: number
provider: PlanProvider
resetDay?: number
setAt: string

19
src/copilot-aiu.ts Normal file
View file

@ -0,0 +1,19 @@
// Copilot AI credits. Official rate from GitHub Docs, *Usage-based billing
// for individuals*, fetched 2026-08-23:
// https://docs.github.com/copilot/concepts/billing/usage-based-billing-for-individuals
// 1e9 nano-AIU = 1 credit = $0.01. No per-model rate table in this slice.
export const NANO_AIU_PER_CREDIT = 1e9
export const AI_CREDIT_USD = 0.01
export function isFiniteNanoAiu(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
export function nanoAiuToCredits(nanoAiu: number): number {
return nanoAiu / NANO_AIU_PER_CREDIT
}
export function creditsToUsd(credits: number): number {
return credits * AI_CREDIT_USD
}

View file

@ -488,9 +488,20 @@ function planColor(planUsage: PlanUsage): string {
// end-first, so the headline stays short enough to keep the percentage visible
// at 80 columns and the status leads with the disclaimer, not the arithmetic.
export function planBudgetHeadline(planUsage: PlanUsage): string {
if (planUsage.plan.provider === 'copilot') {
const spent = planUsage.spentCredits ?? 0
const budget = planUsage.budgetCredits ?? planUsage.plan.monthlyCredits ?? 0
return `${planLabel(planUsage)}: ${formatCredits(spent)} / ${formatCredits(budget)} AI Credits`
}
return `${planLabel(planUsage)}: ${formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent / ${formatCost(planUsage.budgetUsd)} budget`
}
function formatCredits(n: number): string {
if (Number.isInteger(n)) return String(n)
const rounded = Math.round(n * 1e6) / 1e6
return String(rounded)
}
export function planStatusText(planUsage: PlanUsage): string {
// The period is anniversary-based (plan.resetDay, 1-28, settable per plan via
// `codeburn plan set --reset-day`), so this is a monthly budget window, not a

View file

@ -144,10 +144,16 @@ type JsonPlanSummary = {
daysUntilReset: number
periodStart: string
periodEnd: string
monthlyCredits?: number
spentCredits?: number
budgetCredits?: number
creditsIncomplete?: boolean
monthlyUsd?: number
spentApiEquivalentUsd?: number
}
function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
return {
const summary: JsonPlanSummary = {
id: planUsage.plan.id,
provider: planUsage.plan.provider,
budget: convertCost(planUsage.budgetUsd),
@ -159,6 +165,15 @@ function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
periodStart: planUsage.periodStart.toISOString(),
periodEnd: planUsage.periodEnd.toISOString(),
}
if (planUsage.plan.provider === 'copilot') {
summary.monthlyCredits = planUsage.plan.monthlyCredits
summary.spentCredits = planUsage.spentCredits
summary.budgetCredits = planUsage.budgetCredits
summary.creditsIncomplete = planUsage.creditsIncomplete
summary.monthlyUsd = planUsage.plan.monthlyUsd
summary.spentApiEquivalentUsd = planUsage.spentApiEquivalentUsd
}
return summary
}
type JsonPlanSummaryMap = Partial<Record<PlanProvider, JsonPlanSummary>>
@ -383,6 +398,7 @@ function toPlanDisplay(plan: Plan) {
return {
id: plan.id,
monthlyUsd: plan.monthlyUsd,
...(plan.monthlyCredits != null ? { monthlyCredits: plan.monthlyCredits } : {}),
provider: plan.provider,
resetDay: clampResetDay(plan.resetDay),
setAt: plan.setAt || null,
@ -1695,14 +1711,15 @@ program
.description('Show or configure a subscription plan for overage tracking')
.option('--format <format>', 'Output format: text or json', 'text')
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor')
.option('--credits <n>', 'Monthly AI credits (copilot custom plans)', parseNumber)
.option('--provider <name>', `Provider scope: ${PLAN_PROVIDERS.join(', ')}`)
.option('--reset-day <n>', 'Day of month plan resets (1-28)', parseInteger, 1)
.action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
.action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; credits?: number; provider?: string; resetDay?: number }) => {
assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan')
const mode = action ?? 'show'
const providerOption = opts?.provider
if (providerOption !== undefined && !isPlanProvider(providerOption)) {
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${providerOption}".\n`)
console.error(`\n --provider must be one of: ${PLAN_PROVIDERS.join(', ')}; got "${providerOption}".\n`)
process.exitCode = 1
return
}
@ -1731,7 +1748,7 @@ program
console.log(`\n Plans: ${plans.length}`)
for (const plan of plans) {
console.log(` ${plan.provider}: ${planLabel(plan)} (${plan.id})`)
console.log(` Budget: $${plan.monthlyUsd}/month`)
console.log(` Budget: ${plan.provider === 'copilot' && plan.monthlyCredits != null ? `${plan.monthlyCredits} AI Credits` : `$${plan.monthlyUsd}/month`}`)
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
if (plan.setAt) console.log(` Set at: ${plan.setAt}`)
}
@ -1779,18 +1796,56 @@ program
}
if (id === 'custom') {
if (opts?.monthlyUsd === undefined) {
const credits = opts?.credits
const monthlyUsdOpt = opts?.monthlyUsd
const provider = providerOption ?? 'all'
if (credits !== undefined && provider !== 'copilot') {
console.error('\n --credits is only valid with --provider copilot.\n')
process.exitCode = 1
return
}
if (provider === 'copilot') {
if (monthlyUsdOpt !== undefined) {
console.error('\n Copilot custom plans take --credits, not --monthly-usd (units mixed).\n')
process.exitCode = 1
return
}
if (credits === undefined) {
console.error('\n Custom copilot plans require --credits <positive number>.\n')
process.exitCode = 1
return
}
if (!Number.isFinite(credits) || credits <= 0) {
console.error(`\n --credits must be a positive finite number; got ${credits}.\n`)
process.exitCode = 1
return
}
await savePlan({
id: 'custom',
monthlyCredits: credits,
monthlyUsd: credits * 0.01,
provider: 'copilot',
resetDay,
setAt: new Date().toISOString(),
})
console.log(`\n Plan set to custom (${credits} AI Credits, copilot, reset day ${resetDay}).`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
return
}
if (monthlyUsdOpt === undefined) {
console.error('\n Custom plans require --monthly-usd <positive number>.\n')
process.exitCode = 1
return
}
const monthlyUsd = opts.monthlyUsd
const monthlyUsd = monthlyUsdOpt
if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) {
console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`)
console.error(`\n --monthly-usd must be a positive number; got ${monthlyUsdOpt}.\n`)
process.exitCode = 1
return
}
const provider = providerOption ?? 'all'
await savePlan({
id: 'custom',
monthlyUsd,

View file

@ -2493,6 +2493,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
bashCommands: call.bashCommands,
deduplicationKey: call.deduplicationKey,
isEstimated: call.costIsEstimated,
...(call.nanoAiu != null ? { nanoAiu: call.nanoAiu } : {}),
})
const prRefs = extractPrUrlsFromText(call.userMessage)
@ -2580,6 +2581,7 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
...(call.interrupted ? { interrupted: true } : {}),
...(call.userModified ? { userModified: true } : {}),
...(call.toolErrors ? { toolErrors: call.toolErrors } : {}),
...(call.nanoAiu != null ? { nanoAiu: call.nanoAiu } : {}),
activeDurationMs: call.activeDurationMs,
activeGeneratedTokens: call.activeGeneratedTokens,
toolWaitMs: call.toolWaitMs,
@ -2700,6 +2702,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
activeDurationMs: call.activeDurationMs,
activeGeneratedTokens: call.activeGeneratedTokens,
toolWaitMs: call.toolWaitMs,
...(call.nanoAiu != null ? { nanoAiu: call.nanoAiu } : {}),
})
}

View file

@ -1,8 +1,9 @@
import { behavioralCallCount } from './behavioral-weight.js'
import { readPlans, type Plan, type PlanMap } from './config.js'
import { creditsToUsd, isFiniteNanoAiu, nanoAiuToCredits } from './copilot-aiu.js'
import { parseAllSessions } from './parser.js'
import { PLAN_PROVIDERS } from './plans.js'
import type { DateRange, ProjectSummary } from './types.js'
import type { DateRange, ParsedApiCall, ProjectSummary } from './types.js'
const MS_PER_DAY = 24 * 60 * 60 * 1000
const PLAN_NEAR_THRESHOLD_PCT = 80
@ -19,6 +20,10 @@ export type PlanUsage = {
status: PlanStatus
projectedMonthUsd: number
daysUntilReset: number
// Copilot credit-plan fields. Absent on Claude / cursor / grok / custom-USD.
spentCredits?: number
budgetCredits?: number
creditsIncomplete?: boolean
}
export function clampResetDay(resetDay: number | undefined): number {
@ -69,12 +74,46 @@ function diffCalendarDays(from: Date, to: Date): number {
return toDayIndex(to) - toDayIndex(from)
}
export function isCopilotCreditsPlan(plan: Plan): boolean {
return plan.provider === 'copilot'
}
function planCallSpend(plan: Plan, call: { costUSD: number; nanoAiu?: number }): number {
if (!isCopilotCreditsPlan(plan)) return call.costUSD
return isFiniteNanoAiu(call.nanoAiu) ? creditsToUsd(nanoAiuToCredits(call.nanoAiu)) : 0
}
function forEachPlanCall(projects: ProjectSummary[], visit: (call: ParsedApiCall) => void): void {
for (const project of projects) {
for (const session of project.sessions ?? []) {
for (const turn of session.turns ?? []) {
for (const call of turn.assistantCalls ?? []) visit(call)
}
}
}
}
export function copilotCreditSpend(projects: ProjectSummary[]): { spentCredits: number; creditsIncomplete: boolean } {
let nanoSum = 0
let creditsIncomplete = false
forEachPlanCall(projects, call => {
if (call.provider !== 'copilot') return
if (isFiniteNanoAiu(call.nanoAiu)) {
nanoSum += call.nanoAiu
return
}
creditsIncomplete = true
})
return { spentCredits: nanoAiuToCredits(nanoSum), creditsIncomplete }
}
export function projectMonthEnd(
projects: ProjectSummary[],
periodStart: Date,
periodEnd: Date,
today: Date,
spent: number,
spendOf: (call: { costUSD: number; nanoAiu?: number }) => number = (call) => call.costUSD,
): number {
const dayCosts = new Map<string, number>()
@ -88,7 +127,7 @@ export function projectMonthEnd(
if (Number.isNaN(ts.getTime())) continue
if (ts < periodStart || ts > today) continue
const dayKey = toLocalDateKey(ts)
dayCosts.set(dayKey, (dayCosts.get(dayKey) ?? 0) + call.costUSD)
dayCosts.set(dayKey, (dayCosts.get(dayKey) ?? 0) + spendOf(call))
}
}
}
@ -110,12 +149,36 @@ export function projectMonthEnd(
export function getPlanUsageFromProjects(plan: Plan, projects: ProjectSummary[], today = new Date()): PlanUsage {
const { periodStart, periodEnd } = computePeriodFromResetDay(plan.resetDay, today)
const spent = projects.reduce((sum, p) => sum + p.totalCostUSD, 0)
const budgetUsd = plan.monthlyUsd
const daysUntilReset = Math.max(0, diffCalendarDays(today, periodEnd))
if (isCopilotCreditsPlan(plan)) {
const { spentCredits, creditsIncomplete } = copilotCreditSpend(projects)
const budgetCredits = plan.monthlyCredits ?? 0
const percentUsed = budgetCredits > 0 ? (spentCredits / budgetCredits) * 100 : 0
const spent = creditsToUsd(spentCredits)
const status: PlanStatus = percentUsed > 100 ? 'over' : percentUsed >= PLAN_NEAR_THRESHOLD_PCT ? 'near' : 'under'
const projectedMonthUsd = projectMonthEnd(projects, periodStart, periodEnd, today, spent, call => planCallSpend(plan, call))
return {
plan,
periodStart,
periodEnd,
spentApiEquivalentUsd: spent,
budgetUsd,
percentUsed,
status,
projectedMonthUsd,
daysUntilReset,
spentCredits,
budgetCredits,
creditsIncomplete,
}
}
const spent = projects.reduce((sum, p) => sum + p.totalCostUSD, 0)
const percentUsed = budgetUsd > 0 ? (spent / budgetUsd) * 100 : 0
const status: PlanStatus = percentUsed > 100 ? 'over' : percentUsed >= PLAN_NEAR_THRESHOLD_PCT ? 'near' : 'under'
const projectedMonthUsd = projectMonthEnd(projects, periodStart, periodEnd, today, spent)
const daysUntilReset = Math.max(0, diffCalendarDays(today, periodEnd))
return {
plan,
@ -157,17 +220,19 @@ export function getPlanScopedProjects(plan: Plan, projects: ProjectSummary[], to
(sum, turn) => sum + turn.assistantCalls.reduce((turnSum, call) => turnSum + call.costUSD, 0),
0,
)
const hasNanoAiu = turns.some(turn => turn.assistantCalls.some(call => isFiniteNanoAiu(call.nanoAiu)))
const apiCalls = turns.reduce((sum, turn) => sum + behavioralCallCount(turn.assistantCalls), 0)
// Keep on cost as well as calls: a copilot rollup-only session has
// zero behavioral calls but real spend, and dropping it would erase
// that spend from the plan window.
return apiCalls > 0 || totalCostUSD > 0 ? { ...session, turns, totalCostUSD, apiCalls } : null
// that spend from the plan window. A store-row with nanoAiu and $0
// token cost still has to reach credit math.
return apiCalls > 0 || totalCostUSD > 0 || hasNanoAiu ? { ...session, turns, totalCostUSD, apiCalls } : null
})
.filter((session): session is NonNullable<typeof session> => session !== null)
const totalCostUSD = sessions.reduce((sum, session) => sum + session.totalCostUSD, 0)
const totalApiCalls = sessions.reduce((sum, session) => sum + session.apiCalls, 0)
return totalApiCalls > 0 || totalCostUSD > 0 ? { ...project, sessions, totalCostUSD, totalApiCalls } : null
return sessions.length > 0 ? { ...project, sessions, totalCostUSD, totalApiCalls } : null
})
.filter((project): project is NonNullable<typeof project> => project !== null)
}

View file

@ -1,9 +1,19 @@
import type { Plan, PlanId, PlanProvider } from './config.js'
import { AI_CREDIT_USD } from './copilot-aiu.js'
export const PLAN_PROVIDERS: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok']
export const PLAN_IDS: PlanId[] = ['claude-pro', 'claude-max', 'claude-max-5x', 'cursor-pro', 'supergrok', 'supergrok-heavy', 'custom', 'none']
export const PLAN_PROVIDERS: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok', 'copilot']
export const PLAN_IDS: PlanId[] = ['claude-pro', 'claude-max', 'claude-max-5x', 'cursor-pro', 'supergrok', 'supergrok-heavy', 'copilot-pro', 'copilot-pro-plus', 'copilot-max', 'custom', 'none']
export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy', Omit<Plan, 'setAt'>> = {
// Official Copilot individual allotments from GitHub Docs, *Usage-based
// billing for individuals*, fetched 2026-08-23. Flex is documented as
// variable — pin this table, do not invent a later flex number.
// https://docs.github.com/copilot/concepts/billing/usage-based-billing-for-individuals
// monthlyUsd is credits × $0.01 (the budget equivalent), not the sticker price.
const COPILOT_PRO_CREDITS = 1500
const COPILOT_PRO_PLUS_CREDITS = 7000
const COPILOT_MAX_CREDITS = 20000
export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy' | 'copilot-pro' | 'copilot-pro-plus' | 'copilot-max', Omit<Plan, 'setAt'>> = {
'claude-pro': {
id: 'claude-pro',
monthlyUsd: 20,
@ -40,6 +50,27 @@ export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x'
provider: 'grok',
resetDay: 1,
},
'copilot-pro': {
id: 'copilot-pro',
monthlyCredits: COPILOT_PRO_CREDITS,
monthlyUsd: COPILOT_PRO_CREDITS * AI_CREDIT_USD,
provider: 'copilot',
resetDay: 1,
},
'copilot-pro-plus': {
id: 'copilot-pro-plus',
monthlyCredits: COPILOT_PRO_PLUS_CREDITS,
monthlyUsd: COPILOT_PRO_PLUS_CREDITS * AI_CREDIT_USD,
provider: 'copilot',
resetDay: 1,
},
'copilot-max': {
id: 'copilot-max',
monthlyCredits: COPILOT_MAX_CREDITS,
monthlyUsd: COPILOT_MAX_CREDITS * AI_CREDIT_USD,
provider: 'copilot',
resetDay: 1,
},
}
export function isPlanProvider(value: string): value is PlanProvider {
@ -71,6 +102,12 @@ export function planDisplayName(id: PlanId): string {
return 'SuperGrok'
case 'supergrok-heavy':
return 'SuperGrok Heavy'
case 'copilot-pro':
return 'Copilot Pro'
case 'copilot-pro-plus':
return 'Copilot Pro+'
case 'copilot-max':
return 'Copilot Max'
case 'custom':
return 'Custom'
case 'none':

View file

@ -67,11 +67,10 @@ export type ParsedProviderCall = {
// starts the leg's store-row subtraction interval here instead of at the
// previous leg. Omitted when the leg contains no compaction.
compactedAt?: string
// Copilot session-store billing metadata, captured only — no report consumes
// these yet (pricing/display design is upstream #890). total_nano_aiu is the
// request's charged AI-credit amount in nano-AIU (1e9 nano-AIU = 1 credit =
// $0.01); request_multiplier is the model's plan multiplier. Captured now so
// a future consumer needs no re-parse of rows the CLI may prune meanwhile.
// Copilot session-store billing metadata. total_nano_aiu is the request's
// charged AI-credit amount in nano-AIU (1e9 nano-AIU = 1 credit = $0.01);
// plan math sums finite nanoAiu. request_multiplier stays capture-only
// (billing-grade cost rewrite is upstream #890).
nanoAiu?: number
requestMultiplier?: number
turnId?: string

View file

@ -54,9 +54,9 @@ export type CachedCall = {
activeDurationMs?: number
activeGeneratedTokens?: number
toolWaitMs?: number
// Copilot session-store billing metadata (capture-only; no report consumes
// these yet — see ParsedProviderCall). Omitted when the store's schema
// predates the columns.
// Copilot session-store billing metadata. Plan math reads nanoAiu (1e9 = 1
// credit). requestMultiplier stays capture-only. Omitted when the store's
// schema predates the columns.
nanoAiu?: number
requestMultiplier?: number
// Copilot shutdown rollups only: stamp of the last successful in-session

View file

@ -162,6 +162,10 @@ export type ParsedApiCall = {
/// tokens/cost but skip it in apiCalls / model-call / turn counts. Transient:
/// assigned at serve time, never cached.
supplementaryAccounting?: boolean
/// Copilot session-store `total_nano_aiu`. 1e9 nano-AIU = 1 credit = $0.01.
/// Threaded from CachedCall / ParsedProviderCall so plan math can sum credits.
/// Absent on older stores and on JSONL / shutdown-rollup siblings.
nanoAiu?: number
}
export type ToolCall = {

View file

@ -147,4 +147,55 @@ describe('codeburn plan command', () => {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('sets copilot-pro as 1500 credits / $15 equivalent', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
const result = runCli(['plan', 'set', 'copilot-pro'], home)
expect(result.status).toBe(0)
const configPath = join(home, '.config', 'codeburn', 'config.json')
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
plans?: { copilot?: { id?: string; monthlyCredits?: number; monthlyUsd?: number; provider?: string; resetDay?: number } }
}
expect(config.plans?.copilot).toMatchObject({
id: 'copilot-pro',
monthlyCredits: 1500,
monthlyUsd: 15,
provider: 'copilot',
resetDay: 1,
})
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('writes custom copilot credits and rejects mixed units', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
const ok = runCli(['plan', 'set', 'custom', '--credits', '20000', '--provider', 'copilot'], home)
expect(ok.status).toBe(0)
const configPath = join(home, '.config', 'codeburn', 'config.json')
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
plans?: { copilot?: { monthlyCredits?: number; monthlyUsd?: number; provider?: string } }
}
expect(config.plans?.copilot).toMatchObject({
monthlyCredits: 20000,
monthlyUsd: 200,
provider: 'copilot',
})
const mixed = runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'copilot'], home)
expect(mixed.status).not.toBe(0)
expect(mixed.stderr).toContain('units mixed')
const wrongProvider = runCli(['plan', 'set', 'custom', '--credits', '20000', '--provider', 'claude'], home)
expect(wrongProvider.status).not.toBe(0)
expect(wrongProvider.stderr).toContain('--credits is only valid with --provider copilot')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
})

View file

@ -0,0 +1,93 @@
// Serve-hole proof for #943: CachedCall.nanoAiu already survives disk.
// cachedCallToApiCall must thread it onto ParsedApiCall with no CACHE_VERSION bump.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { createRequire } from 'node:module'
import { isSqliteAvailable } from '../src/sqlite.js'
import { CACHE_VERSION, clearLoadCacheMemo } from '../src/session-cache.js'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...p: unknown[]): void }
close(): void
}
function collectNanoAiu(projects: Awaited<ReturnType<typeof parseAllSessions>>): number[] {
return projects
.flatMap(p => p.sessions)
.flatMap(s => s.turns)
.flatMap(t => t.assistantCalls)
.map(c => c.nanoAiu)
.filter((n): n is number => typeof n === 'number')
}
describe.skipIf(!isSqliteAvailable())('cachedCallToApiCall threads nanoAiu', () => {
let tmpHome: string
let tmpCache: string
let dbPath: string
beforeEach(async () => {
tmpHome = await mkdtemp(join(tmpdir(), 'cb-aiu-serve-home-'))
tmpCache = await mkdtemp(join(tmpdir(), 'cb-aiu-serve-cache-'))
dbPath = join(tmpHome, 'session-store.db')
process.env['HOME'] = tmpHome
process.env['USERPROFILE'] = tmpHome
process.env['CODEBURN_CACHE_DIR'] = tmpCache
process.env['CODEBURN_COPILOT_SESSION_STORE_DB'] = dbPath
process.env['CODEBURN_COPILOT_DISABLE_OTEL'] = '1'
process.env['CODEBURN_COPILOT_SESSION_STATE_DIR'] = join(tmpHome, 'no-jsonl')
process.env['CODEBURN_COPILOT_WS_STORAGE_DIR'] = join(tmpHome, 'no-ws')
process.env['CODEBURN_COPILOT_GLOBAL_STORAGE_DIR'] = join(tmpHome, 'no-global')
process.env['CODEBURN_COPILOT_JETBRAINS_DIR'] = join(tmpHome, 'no-jb')
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.exec(`
CREATE TABLE sessions (id TEXT PRIMARY KEY, cwd TEXT, repository TEXT, branch TEXT, created_at TEXT DEFAULT (datetime('now')));
CREATE TABLE assistant_usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
model TEXT NOT NULL,
input_tokens INTEGER, output_tokens INTEGER,
cache_read_tokens INTEGER, cache_write_tokens INTEGER, reasoning_tokens INTEGER,
total_nano_aiu INTEGER, request_multiplier REAL,
created_at TEXT DEFAULT (datetime('now'))
);
`)
db.prepare(`INSERT INTO sessions (id, cwd) VALUES ('sess-aiu', '/tmp/codeburn')`).run()
db.prepare(
`INSERT INTO assistant_usage_events
(session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, total_nano_aiu, request_multiplier, created_at)
VALUES ('sess-aiu', 'claude-sonnet-4-5', 1000, 20, 600, 300, 0, 1500000000, 1.0, '2026-08-05T12:00:00.000Z')`,
).run()
db.close()
})
afterEach(async () => {
clearSessionCache()
clearLoadCacheMemo()
await rm(tmpHome, { recursive: true, force: true })
await rm(tmpCache, { recursive: true, force: true })
})
it('serves nanoAiu on a cold parse and a warm cache read without a CACHE_VERSION bump', async () => {
expect(CACHE_VERSION).toBe(9)
clearSessionCache()
const cold = await parseAllSessions(undefined, 'copilot')
expect(collectNanoAiu(cold)).toEqual([1_500_000_000])
// Drop the in-memory memo only. Disk cache now serves via cachedCallToApiCall.
clearSessionCache()
const warm = await parseAllSessions(undefined, 'copilot')
expect(collectNanoAiu(warm)).toEqual([1_500_000_000])
expect(CACHE_VERSION).toBe(9)
})
})

20
tests/copilot-aiu.test.ts Normal file
View file

@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest'
import { AI_CREDIT_USD, creditsToUsd, isFiniteNanoAiu, nanoAiuToCredits } from '../src/copilot-aiu.js'
describe('copilot AIU helpers', () => {
it('converts 1.5e9 nano-AIU to 1.5 credits and $0.015', () => {
expect(nanoAiuToCredits(1_500_000_000)).toBe(1.5)
expect(creditsToUsd(1.5)).toBeCloseTo(0.015, 12)
expect(1.5 / 1500 * 100).toBeCloseTo(0.1, 12)
expect(1.5 * AI_CREDIT_USD).toBeCloseTo(0.015, 12)
})
it('accepts only finite nanoAiu', () => {
expect(isFiniteNanoAiu(1_500_000_000)).toBe(true)
expect(isFiniteNanoAiu(0)).toBe(true)
expect(isFiniteNanoAiu(undefined)).toBe(false)
expect(isFiniteNanoAiu(Number.NaN)).toBe(false)
expect(isFiniteNanoAiu(Number.POSITIVE_INFINITY)).toBe(false)
})
})

View file

@ -55,6 +55,30 @@ describe('plan budget copy', () => {
expect(planStatusText(cursor)).toContain('Approaching budget')
})
it('labels Copilot as AI Credits, not API-equivalent USD', () => {
const copilot = usage({
plan: {
id: 'copilot-pro',
monthlyCredits: 1500,
monthlyUsd: 15,
provider: 'copilot',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
},
budgetUsd: 15,
spentApiEquivalentUsd: 0.015,
spentCredits: 1.5,
budgetCredits: 1500,
percentUsed: 0.1,
creditsIncomplete: false,
})
expect(planBudgetHeadline(copilot)).toBe('Copilot Pro: 1.5 / 1500 AI Credits')
const status = planStatusText(copilot)
expect(status).toContain('Not a live provider window.')
expect(status).not.toMatch(/GitHub|UTC|copilot_internal/i)
expect(status).not.toMatch(/calendar/i)
})
// computePeriodFromResetDay builds an anniversary window from plan.resetDay
// (1-28, settable with `codeburn plan set --reset-day`), so a plan that resets
// on the 15th is NOT on a calendar month and must never be called one.

View file

@ -410,3 +410,187 @@ describe('getPlanUsage', () => {
}
})
})
function usageCall(overrides: {
provider: string
costUSD: number
timestamp: string
nanoAiu?: number
supplementaryAccounting?: boolean
}) {
return {
provider: overrides.provider,
model: 'claude-sonnet-4-5',
usage: {
inputTokens: 100,
outputTokens: 20,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: overrides.costUSD,
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard' as const,
timestamp: overrides.timestamp,
bashCommands: [],
deduplicationKey: `${overrides.provider}-${overrides.timestamp}-${overrides.nanoAiu ?? 'none'}`,
...(overrides.nanoAiu != null ? { nanoAiu: overrides.nanoAiu } : {}),
...(overrides.supplementaryAccounting ? { supplementaryAccounting: true } : {}),
}
}
function usageProject(calls: ReturnType<typeof usageCall>[]): ProjectSummary {
return {
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: calls.reduce((sum, call) => sum + call.costUSD, 0),
totalApiCalls: calls.length,
sessions: [{
turns: [{
timestamp: calls[0]!.timestamp,
assistantCalls: calls,
}],
}],
} as ProjectSummary
}
describe('copilot AI credit plan math', () => {
const today = new Date('2026-08-10T12:00:00.000Z')
const copilotPro: Plan = {
id: 'copilot-pro',
monthlyCredits: 1500,
monthlyUsd: 15,
provider: 'copilot',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
}
const claudePro: Plan = {
id: 'claude-pro',
monthlyUsd: 20,
provider: 'claude',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
}
it('turns 1.5e9 nanoAiu into 1.5 / 1500 credits (0.1%)', () => {
const usage = getPlanUsageFromProjects(copilotPro, [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 42,
timestamp: '2026-08-05T12:00:00.000Z',
nanoAiu: 1_500_000_000,
}),
]),
], today)
expect(usage.spentCredits).toBe(1.5)
expect(usage.budgetCredits).toBe(1500)
expect(usage.percentUsed).toBeCloseTo(0.1, 10)
expect(usage.spentApiEquivalentUsd).toBeCloseTo(0.015, 10)
expect(usage.creditsIncomplete).toBe(false)
expect(usage.status).toBe('under')
})
it('does not fill the credits bar from token costUSD when nanoAiu is missing', () => {
const usage = getPlanUsageFromProjects(copilotPro, [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 42,
timestamp: '2026-08-05T12:00:00.000Z',
}),
]),
], today)
expect(usage.spentCredits).toBe(0)
expect(usage.creditsIncomplete).toBe(true)
expect(usage.percentUsed).toBe(0)
expect(usage.spentApiEquivalentUsd).toBe(0)
})
it('counts a paired store-row once and ignores JSONL / rollup siblings', () => {
const usage = getPlanUsageFromProjects(copilotPro, [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 0.001605,
timestamp: '2026-08-05T12:00:00.000Z',
nanoAiu: 1_500_000_000,
supplementaryAccounting: true,
}),
usageCall({
provider: 'copilot',
costUSD: 0.001605,
timestamp: '2026-08-05T12:00:01.000Z',
}),
usageCall({
provider: 'copilot',
costUSD: 0.25,
timestamp: '2026-08-06T12:00:00.000Z',
supplementaryAccounting: true,
}),
]),
], today)
expect(usage.spentCredits).toBe(1.5)
expect(usage.percentUsed).toBeCloseTo(0.1, 10)
expect(usage.creditsIncomplete).toBe(true)
})
it('keeps Claude plans on costUSD against the same mixed fixture', () => {
const projects = [
usageProject([
usageCall({
provider: 'copilot',
costUSD: 42,
timestamp: '2026-08-05T12:00:00.000Z',
nanoAiu: 1_500_000_000,
}),
usageCall({
provider: 'claude',
costUSD: 5,
timestamp: '2026-08-05T12:00:00.000Z',
}),
]),
]
const scopedClaude = getPlanScopedProjects(claudePro, projects, today)
const claudeUsage = getPlanUsageFromProjects(claudePro, scopedClaude, today)
const copilotUsage = getPlanUsageFromProjects(copilotPro, getPlanScopedProjects(copilotPro, projects, today), today)
expect(claudeUsage.spentApiEquivalentUsd).toBe(5)
expect(claudeUsage.spentCredits).toBeUndefined()
expect(claudeUsage.percentUsed).toBe(25)
expect(copilotUsage.spentCredits).toBe(1.5)
expect(copilotUsage.percentUsed).toBeCloseTo(0.1, 10)
})
it('leaves cursor and grok USD siblings at 25%', () => {
const cursor = getPlanUsageFromProjects({
id: 'cursor-pro',
monthlyUsd: 20,
provider: 'cursor',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
}, [usageProject([usageCall({ provider: 'cursor', costUSD: 5, timestamp: '2026-08-05T12:00:00.000Z' })])], today)
const grok = getPlanUsageFromProjects({
id: 'supergrok',
monthlyUsd: 30,
provider: 'grok',
resetDay: 1,
setAt: '2026-08-01T00:00:00.000Z',
}, [usageProject([usageCall({ provider: 'grok', costUSD: 7.5, timestamp: '2026-08-05T12:00:00.000Z' })])], today)
expect(cursor.percentUsed).toBe(25)
expect(grok.percentUsed).toBe(25)
expect(cursor.spentCredits).toBeUndefined()
expect(grok.spentCredits).toBeUndefined()
})
})

View file

@ -12,6 +12,9 @@ describe('plan presets', () => {
expect(getPresetPlan('claude-pro')).toMatchObject({ id: 'claude-pro', monthlyUsd: 20, provider: 'claude' })
expect(getPresetPlan('claude-max')).toMatchObject({ id: 'claude-max', monthlyUsd: 200, provider: 'claude' })
expect(getPresetPlan('cursor-pro')).toMatchObject({ id: 'cursor-pro', monthlyUsd: 20, provider: 'cursor' })
expect(getPresetPlan('copilot-pro')).toMatchObject({ id: 'copilot-pro', monthlyCredits: 1500, monthlyUsd: 15, provider: 'copilot' })
expect(getPresetPlan('copilot-pro-plus')).toMatchObject({ id: 'copilot-pro-plus', monthlyCredits: 7000, monthlyUsd: 70, provider: 'copilot' })
expect(getPresetPlan('copilot-max')).toMatchObject({ id: 'copilot-max', monthlyCredits: 20000, monthlyUsd: 200, provider: 'copilot' })
expect(getPresetPlan('custom')).toBeNull()
})
@ -22,7 +25,9 @@ describe('plan presets', () => {
expect(isPlanProvider('all')).toBe(true)
expect(isPlanProvider('claude')).toBe(true)
expect(isPlanProvider('copilot')).toBe(true)
expect(isPlanProvider('invalid')).toBe(false)
expect(isPlanId('copilot-pro')).toBe(true)
})
})