mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
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:
parent
c9dab7deb6
commit
0c7d92a312
19 changed files with 609 additions and 32 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
19
src/copilot-aiu.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
73
src/main.ts
73
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<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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
43
src/plans.ts
43
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<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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
93
tests/copilot-aiu-serve.test.ts
Normal file
93
tests/copilot-aiu-serve.test.ts
Normal 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
20
tests/copilot-aiu.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue