codeburn/src/plan-usage.ts
Aditya Vikram Singh 57e897c74a fix(plan): close Copilot nanoAiu twins once per session
foldCopilotSupplementaryTurns will not fold a supplementary twin across
a local-day boundary, so the pair can occupy separate turns. Credit
spend now groups at session scope.
2026-08-24 18:35:59 +05:30

296 lines
12 KiB
TypeScript

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, ParsedApiCall, ProjectSummary } from './types.js'
const MS_PER_DAY = 24 * 60 * 60 * 1000
const PLAN_NEAR_THRESHOLD_PCT = 80
export type PlanStatus = 'under' | 'near' | 'over'
export type PlanUsage = {
plan: Plan
periodStart: Date
periodEnd: Date
spentApiEquivalentUsd: number
budgetUsd: number
percentUsed: number
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 {
if (!Number.isInteger(resetDay)) return 1
return Math.min(28, Math.max(1, resetDay ?? 1))
}
export function computePeriodFromResetDay(resetDay: number | undefined, today: Date): { periodStart: Date; periodEnd: Date } {
const day = clampResetDay(resetDay)
const year = today.getFullYear()
const month = today.getMonth()
if (today.getDate() >= day) {
return {
periodStart: new Date(year, month, day, 0, 0, 0, 0),
periodEnd: new Date(year, month + 1, day, 0, 0, 0, 0),
}
}
return {
periodStart: new Date(year, month - 1, day, 0, 0, 0, 0),
periodEnd: new Date(year, month, day, 0, 0, 0, 0),
}
}
function median(values: number[]): number {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2
}
return sorted[mid]!
}
function toLocalDateKey(d: Date): string {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function toDayIndex(d: Date): number {
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / MS_PER_DAY)
}
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 forEachPlanSession(projects: ProjectSummary[], visit: (calls: ParsedApiCall[]) => void): void {
for (const project of projects) {
for (const session of project.sessions ?? []) {
const calls: ParsedApiCall[] = []
for (const turn of session.turns ?? []) {
calls.push(...(turn.assistantCalls ?? []))
}
visit(calls)
}
}
}
export function copilotCreditSpend(projects: ProjectSummary[]): { spentCredits: number; creditsIncomplete: boolean } {
let nanoSum = 0
let creditsIncomplete = false
// Pairing is session-wide. foldCopilotSupplementaryTurns will not fold a
// supplementary twin across a local-day boundary, so both nanoAiu rows can
// occupy separate turns of the same session. Close the twin once here.
forEachPlanSession(projects, calls => {
const copilot = calls.filter(call => call.provider === 'copilot')
if (copilot.length === 0) return
const primaryNano = copilot.filter(call => !call.supplementaryAccounting && isFiniteNanoAiu(call.nanoAiu))
const suppNano = copilot.filter(call => call.supplementaryAccounting && isFiniteNanoAiu(call.nanoAiu))
// Store rows are supplementary and are the bill when the JSONL twin has
// no nanoAiu. If both sides carry nanoAiu, count the behavioral row only
// so a paired rollup cannot double the credits.
const counted = primaryNano.length > 0 && suppNano.length > 0 ? primaryNano : [...primaryNano, ...suppNano]
for (const call of counted) nanoSum += call.nanoAiu!
if (copilot.some(call => !isFiniteNanoAiu(call.nanoAiu))) 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>()
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
for (const call of turn.assistantCalls) {
const timestamp = call.timestamp || turn.timestamp
if (!timestamp) continue
const ts = new Date(timestamp)
if (Number.isNaN(ts.getTime())) continue
if (ts < periodStart || ts > today) continue
const dayKey = toLocalDateKey(ts)
dayCosts.set(dayKey, (dayCosts.get(dayKey) ?? 0) + spendOf(call))
}
}
}
}
const elapsedDays = Math.max(1, diffCalendarDays(periodStart, today) + 1)
const elapsedDailyCosts: number[] = []
for (let i = 0; i < elapsedDays; i++) {
const date = new Date(periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate() + i)
elapsedDailyCosts.push(dayCosts.get(toLocalDateKey(date)) ?? 0)
}
const trailingWindow = elapsedDailyCosts.slice(-7)
const medianDailyCost = median(trailingWindow)
const daysRemaining = Math.max(0, diffCalendarDays(today, periodEnd) - 1)
return spent + medianDailyCost * daysRemaining
}
export function getPlanUsageFromProjects(plan: Plan, projects: ProjectSummary[], today = new Date()): PlanUsage {
const { periodStart, periodEnd } = computePeriodFromResetDay(plan.resetDay, today)
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)
return {
plan,
periodStart,
periodEnd,
spentApiEquivalentUsd: spent,
budgetUsd,
percentUsed,
status,
projectedMonthUsd,
daysUntilReset,
}
}
export function getPlanScopedProjects(plan: Plan, projects: ProjectSummary[], today: Date): ProjectSummary[] {
const { periodStart } = computePeriodFromResetDay(plan.resetDay, today)
const provider = plan.provider
// These scoped clones are consumed only by plan usage math; cost/call rollups
// are recomputed below, while unrelated breakdown fields remain unchanged.
return projects
.map(project => {
const sessions = project.sessions
.map(session => {
const turns = session.turns
.map(turn => {
const assistantCalls = turn.assistantCalls.filter(call => {
if (provider !== 'all' && call.provider !== provider) return false
const timestamp = call.timestamp || turn.timestamp
if (!timestamp) return false
const ts = new Date(timestamp)
return !Number.isNaN(ts.getTime()) && ts >= periodStart && ts <= today
})
return assistantCalls.length > 0 ? { ...turn, assistantCalls } : null
})
.filter((turn): turn is NonNullable<typeof turn> => turn !== null)
const totalCostUSD = turns.reduce(
(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. A store-row with nanoAiu and $0
// token cost still has to reach credit math — but only on a copilot
// credit plan. Claude/cursor/grok USD plans keep the old
// cost-or-calls predicate so a $0 nanoAiu-only session is not an
// unannounced retain on providers this PR is not about.
const keepForCredits = isCopilotCreditsPlan(plan) && hasNanoAiu
return apiCalls > 0 || totalCostUSD > 0 || keepForCredits ? { ...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 sessions.length > 0 ? { ...project, sessions, totalCostUSD, totalApiCalls } : null
})
.filter((project): project is NonNullable<typeof project> => project !== null)
}
export async function getPlanUsage(plan: Plan, today = new Date()): Promise<PlanUsage> {
const { periodStart } = computePeriodFromResetDay(plan.resetDay, today)
const range: DateRange = {
start: periodStart,
end: today,
}
const projects = await parseAllSessions(range, plan.provider)
return getPlanUsageFromProjects(plan, projects, today)
}
export async function getPlanUsageOrNull(today = new Date()): Promise<PlanUsage | null> {
return (await getPlanUsages(today))[0] ?? null
}
export function activePlansFromMap(plans: PlanMap): Plan[] {
return PLAN_PROVIDERS
.map(provider => plans[provider])
.filter(isActivePlan)
}
export async function getPlanUsages(today = new Date()): Promise<PlanUsage[]> {
const plans = activePlansFromMap(await readPlans())
if (plans.length === 0) return []
const starts = plans.map(plan => computePeriodFromResetDay(plan.resetDay, today).periodStart.getTime())
const range: DateRange = {
start: new Date(Math.min(...starts)),
end: today,
}
if (plans.length === 1) {
const plan = plans[0]!
const projects = await parseAllSessions(range, plan.provider)
return [getPlanUsageFromProjects(plan, projects, today)]
}
const projects = await parseAllSessions(range, 'all')
return plans.map(plan => getPlanUsageFromProjects(plan, getPlanScopedProjects(plan, projects, today), today))
}
export function isActivePlan(plan: Plan | undefined): plan is Plan {
return plan !== undefined && plan.id !== 'none' && Number.isFinite(plan.monthlyUsd) && plan.monthlyUsd > 0
}