diff --git a/CHANGELOG.md b/CHANGELOG.md index e21dddda..441bb549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/README.md b/README.md index 78ac72f1..bf1f903a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index 62926388..45fbdeab 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -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 diff --git a/src/config.ts b/src/config.ts index 742a7d65..d96c0442 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 diff --git a/src/copilot-aiu.ts b/src/copilot-aiu.ts new file mode 100644 index 00000000..115da442 --- /dev/null +++ b/src/copilot-aiu.ts @@ -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 +} diff --git a/src/dashboard.tsx b/src/dashboard.tsx index b67f3b97..f902727f 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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 diff --git a/src/main.ts b/src/main.ts index 61efb9ba..85015206 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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> @@ -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 ', 'Output format: text or json', 'text') .option('--monthly-usd ', 'Monthly plan price in USD (for custom)', parseNumber) - .option('--provider ', 'Provider scope: all, claude, codex, cursor') + .option('--credits ', 'Monthly AI credits (copilot custom plans)', parseNumber) + .option('--provider ', `Provider scope: ${PLAN_PROVIDERS.join(', ')}`) .option('--reset-day ', '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 .\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 .\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, diff --git a/src/parser.ts b/src/parser.ts index 1ee1cb15..35542b23 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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 } : {}), }) } diff --git a/src/plan-usage.ts b/src/plan-usage.ts index 85444124..19243fd3 100644 --- a/src/plan-usage.ts +++ b/src/plan-usage.ts @@ -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() @@ -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 => 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 => project !== null) } diff --git a/src/plans.ts b/src/plans.ts index e384e75c..7488c3a1 100644 --- a/src/plans.ts +++ b/src/plans.ts @@ -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> = { +// 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> = { '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': diff --git a/src/providers/types.ts b/src/providers/types.ts index b9671d9d..357d5eef 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -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 diff --git a/src/session-cache.ts b/src/session-cache.ts index 30047f2e..3d76c90f 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -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 diff --git a/src/types.ts b/src/types.ts index 34451ac1..81dd6f86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { diff --git a/tests/cli-plan.test.ts b/tests/cli-plan.test.ts index 14e2b121..cae7d051 100644 --- a/tests/cli-plan.test.ts +++ b/tests/cli-plan.test.ts @@ -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) }) diff --git a/tests/copilot-aiu-serve.test.ts b/tests/copilot-aiu-serve.test.ts new file mode 100644 index 00000000..5d502a3f --- /dev/null +++ b/tests/copilot-aiu-serve.test.ts @@ -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>): 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) + }) +}) diff --git a/tests/copilot-aiu.test.ts b/tests/copilot-aiu.test.ts new file mode 100644 index 00000000..88d9f9b9 --- /dev/null +++ b/tests/copilot-aiu.test.ts @@ -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) + }) +}) diff --git a/tests/plan-budget-copy.test.ts b/tests/plan-budget-copy.test.ts index 787df94f..c6bc8793 100644 --- a/tests/plan-budget-copy.test.ts +++ b/tests/plan-budget-copy.test.ts @@ -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. diff --git a/tests/plan-usage.test.ts b/tests/plan-usage.test.ts index 94cc7521..dffa5ce2 100644 --- a/tests/plan-usage.test.ts +++ b/tests/plan-usage.test.ts @@ -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[]): 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() + }) +}) diff --git a/tests/plans.test.ts b/tests/plans.test.ts index ca4553b3..2a4c7e49 100644 --- a/tests/plans.test.ts +++ b/tests/plans.test.ts @@ -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) }) })