mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
* feat(codex): compute Codex credit usage (#408, #495) Codex/ChatGPT subscription users consume credits, a unit separate from API dollars: usage is billed as credits-per-million-tokens at per-model rates that differ from the API USD pricing CodeBurn uses for cost. So the reported dollar cost does not match what credits actually consume. Add a credit engine sourced from the official Codex credit rates (developers.openai.com/codex/pricing): GPT-5.5 125/12.5/750, GPT-5.4 62.5/6.25/375, GPT-5.4 mini 18.75/1.875/113 credits per 1M input/cached/output tokens. Surface per-model credit usage in `codeburn models` JSON output (credits field; null for non-Codex or unknown models). models-report already folds reasoning into output and keeps non-cached input + cached-read separately, which is exactly what the credit rates expect, so the figure is exact. Engine + computation are unit-tested. UI display surfaces (the models table, the TUI dashboard, the menubar "credits" view) are intentionally left for a follow-up so the display choice can be decided. * feat(menubar): opt-in Codex credits display metric (#408, #495) Surface Codex credit usage in the menubar as a selectable metric, without changing the default. Cost ($) stays the default in both the menubar and the CLI; credits only appear when explicitly chosen. - TS: buildMenubarPayloadForRange computes the period's Codex credits (via the tested aggregateModels, so reasoning/cached are handled) and exposes current.codexCredits in the menubar JSON. - Swift: new DisplayMetric.credits, a "Credits (Codex)" option in the metric picker, decodes codexCredits, and renders it in the menu-bar title. Default metric remains .cost.
702 lines
27 KiB
TypeScript
702 lines
27 KiB
TypeScript
import chalk from 'chalk'
|
|
import stripAnsi from 'strip-ansi'
|
|
|
|
import { codexCredits } from './codex-credits.js'
|
|
import { formatCost, formatTokens } from './format.js'
|
|
import { getProvider } from './providers/index.js'
|
|
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
|
|
|
export type ModelReportRow = {
|
|
provider: string
|
|
providerDisplayName: string
|
|
model: string
|
|
modelDisplayName: string
|
|
category: TaskCategory | null
|
|
inputTokens: number
|
|
outputTokens: number
|
|
cacheWriteTokens: number
|
|
cacheReadTokens: number
|
|
totalTokens: number
|
|
costUSD: number
|
|
savingsUSD: number
|
|
savingsBaselineModel: string
|
|
calls: number
|
|
/// Codex credit consumption (issues #408/#495). null for non-Codex models or
|
|
/// Codex models without a known credit rate.
|
|
credits: number | null
|
|
topCategory?: TaskCategory
|
|
topCategoryCost?: number
|
|
topCategoryShare?: number
|
|
}
|
|
|
|
export type AggregateOptions = {
|
|
byTask?: boolean
|
|
taskFilter?: TaskCategory
|
|
topN?: number
|
|
/// Threshold for the `cost`-based filter. The default `0.01` would
|
|
/// hide local-only models whose `costUSD` is 0 but `savingsUSD` is
|
|
/// meaningful. The implementation ORs in `savingsUSD >= minCost`
|
|
/// so saved-only rows still surface by default.
|
|
minCost?: number
|
|
}
|
|
|
|
type Bucket = {
|
|
provider: string
|
|
model: string
|
|
category: TaskCategory | null
|
|
inputTokens: number
|
|
outputTokens: number
|
|
cacheWriteTokens: number
|
|
cacheReadTokens: number
|
|
costUSD: number
|
|
savingsUSD: number
|
|
savingsBaselineModel: string
|
|
calls: number
|
|
}
|
|
|
|
type ModelKey = string
|
|
type CategoryKey = TaskCategory
|
|
|
|
function bucketKey(provider: string, model: string, category: TaskCategory | null): string {
|
|
return `${provider} ${model} ${category ?? ''}`
|
|
}
|
|
|
|
/// Walks every parsed turn, attributes each assistant call to a
|
|
/// (provider, model, category) bucket, and returns rows keyed by either
|
|
/// (provider, model) when `byTask` is false or (provider, model, category) when true.
|
|
///
|
|
/// Default view: rows sorted by cost descending.
|
|
/// byTask view: rows grouped by (provider, model) so the renderer can blank
|
|
/// repeated provider/model cells. Group order follows total cost across that
|
|
/// model; within each group, rows go by cost descending.
|
|
export async function aggregateModels(projects: ProjectSummary[], opts: AggregateOptions = {}): Promise<ModelReportRow[]> {
|
|
const buckets = new Map<string, Bucket>()
|
|
const perModelCategoryCost = new Map<ModelKey, Map<CategoryKey, number>>()
|
|
const perModelTotalCost = new Map<ModelKey, number>()
|
|
|
|
for (const project of projects) {
|
|
for (const session of project.sessions) {
|
|
for (const turn of session.turns) {
|
|
if (opts.taskFilter && turn.category !== opts.taskFilter) continue
|
|
for (const call of turn.assistantCalls) {
|
|
const provider = call.provider || 'unknown'
|
|
const model = call.model || 'unknown'
|
|
const category: TaskCategory | null = opts.byTask ? turn.category : null
|
|
const key = bucketKey(provider, model, category)
|
|
let bucket = buckets.get(key)
|
|
if (!bucket) {
|
|
bucket = {
|
|
provider,
|
|
model,
|
|
category,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
cacheReadTokens: 0,
|
|
costUSD: 0,
|
|
savingsUSD: 0,
|
|
savingsBaselineModel: '',
|
|
calls: 0,
|
|
}
|
|
buckets.set(key, bucket)
|
|
}
|
|
bucket.inputTokens += call.usage.inputTokens
|
|
bucket.outputTokens += call.usage.outputTokens + call.usage.reasoningTokens
|
|
bucket.cacheWriteTokens += call.usage.cacheCreationInputTokens
|
|
// cacheReadInputTokens (Anthropic vocab) and cachedInputTokens (OpenAI vocab)
|
|
// are two names for the same thing. Providers populate one or set both to the
|
|
// same value, so take the max — summing double-counts cache reads for every
|
|
// provider that fills both fields.
|
|
bucket.cacheReadTokens += Math.max(call.usage.cacheReadInputTokens, call.usage.cachedInputTokens)
|
|
bucket.costUSD += call.costUSD
|
|
bucket.savingsUSD += call.savingsUSD ?? 0
|
|
if (!bucket.savingsBaselineModel && call.savingsBaselineModel) {
|
|
bucket.savingsBaselineModel = call.savingsBaselineModel
|
|
}
|
|
bucket.calls += 1
|
|
|
|
const modelKey = `${provider} ${model}`
|
|
let perCat = perModelCategoryCost.get(modelKey)
|
|
if (!perCat) {
|
|
perCat = new Map()
|
|
perModelCategoryCost.set(modelKey, perCat)
|
|
}
|
|
perCat.set(turn.category, (perCat.get(turn.category) ?? 0) + call.costUSD)
|
|
perModelTotalCost.set(modelKey, (perModelTotalCost.get(modelKey) ?? 0) + call.costUSD)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const providerCache = new Map<string, { displayName: string; formatModel: (m: string) => string }>()
|
|
async function resolveProvider(name: string) {
|
|
const cached = providerCache.get(name)
|
|
if (cached) return cached
|
|
const p = await getProvider(name)
|
|
const entry = {
|
|
displayName: p?.displayName ?? name,
|
|
formatModel: p ? (m: string) => p.modelDisplayName(m) : (m: string) => m,
|
|
}
|
|
providerCache.set(name, entry)
|
|
return entry
|
|
}
|
|
|
|
const rows: ModelReportRow[] = []
|
|
for (const bucket of buckets.values()) {
|
|
const meta = await resolveProvider(bucket.provider)
|
|
const total = bucket.inputTokens + bucket.outputTokens + bucket.cacheWriteTokens + bucket.cacheReadTokens
|
|
const row: ModelReportRow = {
|
|
provider: bucket.provider,
|
|
providerDisplayName: meta.displayName,
|
|
model: bucket.model,
|
|
modelDisplayName: meta.formatModel(bucket.model),
|
|
category: bucket.category,
|
|
inputTokens: bucket.inputTokens,
|
|
outputTokens: bucket.outputTokens,
|
|
cacheWriteTokens: bucket.cacheWriteTokens,
|
|
cacheReadTokens: bucket.cacheReadTokens,
|
|
totalTokens: total,
|
|
costUSD: bucket.costUSD,
|
|
savingsUSD: bucket.savingsUSD,
|
|
savingsBaselineModel: bucket.savingsBaselineModel,
|
|
calls: bucket.calls,
|
|
// outputTokens already includes reasoning (folded in above), and for Codex
|
|
// inputTokens is non-cached with cacheReadTokens holding cached input, which
|
|
// is exactly what the credit rates expect.
|
|
credits: bucket.provider === 'codex'
|
|
? codexCredits(bucket.model, {
|
|
inputTokens: bucket.inputTokens,
|
|
cachedReadTokens: bucket.cacheReadTokens,
|
|
outputTokens: bucket.outputTokens,
|
|
})
|
|
: null,
|
|
}
|
|
|
|
if (!opts.byTask) {
|
|
const perCat = perModelCategoryCost.get(`${bucket.provider} ${bucket.model}`)
|
|
if (perCat && perCat.size > 0) {
|
|
let topCat: TaskCategory = 'general'
|
|
let topCost = -1
|
|
let totalCost = 0
|
|
for (const [cat, cost] of perCat.entries()) {
|
|
totalCost += cost
|
|
if (cost > topCost) {
|
|
topCost = cost
|
|
topCat = cat
|
|
}
|
|
}
|
|
row.topCategory = topCat
|
|
row.topCategoryCost = topCost
|
|
row.topCategoryShare = totalCost > 0 ? topCost / totalCost : 0
|
|
}
|
|
}
|
|
|
|
rows.push(row)
|
|
}
|
|
|
|
if (opts.byTask) {
|
|
rows.sort((a, b) => {
|
|
const aTotal = perModelTotalCost.get(`${a.provider} ${a.model}`) ?? 0
|
|
const bTotal = perModelTotalCost.get(`${b.provider} ${b.model}`) ?? 0
|
|
if (aTotal !== bTotal) return bTotal - aTotal
|
|
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider)
|
|
if (a.model !== b.model) return a.model.localeCompare(b.model)
|
|
return (b.costUSD + b.savingsUSD) - (a.costUSD + a.savingsUSD)
|
|
})
|
|
} else {
|
|
rows.sort((a, b) => (b.costUSD + b.savingsUSD) - (a.costUSD + a.savingsUSD))
|
|
}
|
|
|
|
let filtered = rows
|
|
if (opts.minCost !== undefined) {
|
|
// OR with savings so local models with $0 actual cost but > 0 saved
|
|
// counterfactual still surface in the default `models` view.
|
|
filtered = filtered.filter(r => r.costUSD >= opts.minCost! || r.savingsUSD >= opts.minCost!)
|
|
}
|
|
if (opts.topN !== undefined) {
|
|
filtered = filtered.slice(0, opts.topN)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
function visibleLength(text: string): number {
|
|
return stripAnsi(text).length
|
|
}
|
|
|
|
function pad(text: string, width: number, align: 'left' | 'right' = 'left'): string {
|
|
const visible = visibleLength(text)
|
|
if (visible >= width) return text
|
|
const filler = ' '.repeat(width - visible)
|
|
return align === 'left' ? text + filler : filler + text
|
|
}
|
|
|
|
function categoryLabel(c: TaskCategory): string {
|
|
return CATEGORY_LABELS[c] ?? c
|
|
}
|
|
|
|
/// Box-drawing preset matching tokscale's comfy-table layout. Pure Unicode;
|
|
/// every modern terminal handles these. JSON / CSV / Markdown formats already
|
|
/// cover the no-Unicode case for downstream tooling.
|
|
const BOX = {
|
|
topLeft: '┌',
|
|
topRight: '┐',
|
|
bottomLeft: '└',
|
|
bottomRight: '┘',
|
|
topT: '┬',
|
|
bottomT: '┴',
|
|
leftT: '├',
|
|
rightT: '┤',
|
|
cross: '┼',
|
|
horizontal: '─',
|
|
vertical: '│',
|
|
}
|
|
|
|
type Column = {
|
|
header: string
|
|
align: 'left' | 'right'
|
|
width: number
|
|
/// Drop priority. 0 = always shown; higher numbers get dropped first when
|
|
/// the terminal is narrow.
|
|
priority: number
|
|
key: 'provider' | 'model' | 'task' | 'input' | 'output' | 'cacheWrite' | 'cacheRead' | 'total' | 'cost' | 'saved'
|
|
}
|
|
|
|
type TableRenderOptions = {
|
|
byTask?: boolean
|
|
showTotals?: boolean
|
|
terminalWidth?: number
|
|
fullWidth?: boolean
|
|
}
|
|
|
|
const DROP_COLUMN_GROUPS: Array<Array<Column['key']>> = [
|
|
['cacheWrite', 'cacheRead'],
|
|
['input', 'output'],
|
|
['task'],
|
|
['saved'],
|
|
]
|
|
|
|
function defaultColumns(byTask: boolean, showSaved: boolean): Column[] {
|
|
// Higher priority numbers drop FIRST when the terminal is narrow.
|
|
// Cache columns are the cheapest to lose, then input/output, then top-task.
|
|
// Provider/Model/Total/Cost stay regardless. The Saved column only appears
|
|
// when local-model savings actually exist (a `codeburn model-savings`
|
|
// mapping produced nonzero avoided spend); otherwise it would be a column of
|
|
// dashes for the majority of users, so it is omitted entirely.
|
|
// Widths are MINIMUMS; sizeColumnsToContent() expands them to fit cell text.
|
|
return [
|
|
{ key: 'provider', header: 'Provider', align: 'left', width: 8, priority: 0 },
|
|
{ key: 'model', header: 'Model', align: 'left', width: 8, priority: 0 },
|
|
{ key: 'task', header: byTask ? 'Task' : 'Top Task', align: 'left', width: 8, priority: 1 },
|
|
{ key: 'input', header: 'Input', align: 'right', width: 6, priority: 2 },
|
|
{ key: 'output', header: 'Output', align: 'right', width: 6, priority: 2 },
|
|
{ key: 'cacheWrite', header: 'Cache Write', align: 'right', width: 11, priority: 3 },
|
|
{ key: 'cacheRead', header: 'Cache Read', align: 'right', width: 10, priority: 3 },
|
|
{ key: 'total', header: 'Total', align: 'right', width: 6, priority: 0 },
|
|
{ key: 'cost', header: 'Cost', align: 'right', width: 6, priority: 0 },
|
|
...(showSaved ? [{ key: 'saved' as const, header: 'Saved', align: 'right' as const, width: 6, priority: 0 }] : []),
|
|
]
|
|
}
|
|
|
|
/// Expands each column's width to fit the widest cell in that column, so a
|
|
/// short header (e.g. "Task") in a fixed 18-wide cell does not leave 14 chars
|
|
/// of trailing whitespace. Mirrors cli-table3 / comfy-table auto-sizing.
|
|
function sizeColumnsToContent(columns: Column[], rows: string[][]): Column[] {
|
|
return columns.map((col, i) => {
|
|
let maxLen = visibleLength(col.header)
|
|
for (const row of rows) {
|
|
const cell = row[i] ?? ''
|
|
const len = visibleLength(cell)
|
|
if (len > maxLen) maxLen = len
|
|
}
|
|
return { ...col, width: Math.max(col.width, maxLen) }
|
|
})
|
|
}
|
|
|
|
function frameWidth(columns: Column[]): number {
|
|
if (columns.length === 0) return 0
|
|
// 1 (left border) + sum(col + 2 padding) + (N-1) inner separators + 1 (right border)
|
|
return 2 + columns.reduce((acc, c) => acc + c.width + 2, 0) + (columns.length - 1)
|
|
}
|
|
|
|
function chooseColumns(byTask: boolean, available: number, showSaved: boolean): Column[] {
|
|
const all = defaultColumns(byTask, showSaved)
|
|
if (frameWidth(all) <= available) return all
|
|
|
|
// Drop in this order so the table degrades sensibly. Cache columns drop as
|
|
// a pair (showing only one of cache write / cache read looks broken).
|
|
const kept = new Set(all)
|
|
for (const group of DROP_COLUMN_GROUPS) {
|
|
for (const key of group) {
|
|
const col = all.find(c => c.key === key)
|
|
if (col) kept.delete(col)
|
|
}
|
|
const remaining = all.filter(c => kept.has(c))
|
|
if (frameWidth(remaining) <= available) return remaining
|
|
}
|
|
return all.filter(c => c.priority === 0)
|
|
}
|
|
|
|
function expandedColumnWeight(col: Column): number {
|
|
switch (col.key) {
|
|
case 'task':
|
|
case 'model':
|
|
return 3
|
|
case 'provider':
|
|
return 2
|
|
default:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
/// Expands a fitted table to the available terminal width. The extra cells are
|
|
/// spread across all visible columns, weighted toward text columns so grouped
|
|
/// model/task rows breathe on wide terminals without turning numeric columns
|
|
/// into huge empty gutters.
|
|
function expandColumnsToWidth(columns: Column[], targetWidth: number): Column[] {
|
|
let remaining = targetWidth - frameWidth(columns)
|
|
if (remaining <= 0 || columns.length === 0) return columns
|
|
|
|
const expanded = columns.map(c => ({ ...c }))
|
|
const weights = expanded.map(expandedColumnWeight)
|
|
const totalWeight = weights.reduce((sum, w) => sum + w, 0)
|
|
|
|
for (let i = 0; i < expanded.length; i++) {
|
|
const add = Math.floor((targetWidth - frameWidth(columns)) * (weights[i]! / totalWeight))
|
|
if (add <= 0) continue
|
|
expanded[i]!.width += add
|
|
remaining -= add
|
|
}
|
|
|
|
// Hand out rounding leftovers in the same preference order.
|
|
const preferred: Column['key'][] = ['task', 'model', 'provider', 'total', 'cost', 'input', 'output', 'cacheRead', 'cacheWrite']
|
|
while (remaining > 0) {
|
|
let changed = false
|
|
for (const key of preferred) {
|
|
const col = expanded.find(c => c.key === key)
|
|
if (!col) continue
|
|
col.width += 1
|
|
remaining -= 1
|
|
changed = true
|
|
if (remaining === 0) break
|
|
}
|
|
if (!changed) break
|
|
}
|
|
|
|
return expanded
|
|
}
|
|
|
|
function renderRow(cells: string[], columns: Column[]): string {
|
|
const padded = cells.map((c, i) => pad(c, columns[i]!.width, columns[i]!.align))
|
|
return BOX.vertical + ' ' + padded.join(' ' + BOX.vertical + ' ') + ' ' + BOX.vertical
|
|
}
|
|
|
|
function renderBorder(columns: Column[], left: string, mid: string, right: string): string {
|
|
const segments = columns.map(c => BOX.horizontal.repeat(c.width + 2))
|
|
return left + segments.join(mid) + right
|
|
}
|
|
|
|
function defaultTerminalWidth(): number {
|
|
const cols = process.stdout.columns
|
|
if (typeof cols === 'number' && cols > 0) return cols
|
|
// Honor $COLUMNS when stdout is not a TTY (piped, tee'd, etc.); some
|
|
// shells set it even when isTTY is false.
|
|
const envCols = process.env['COLUMNS'] ? parseInt(process.env['COLUMNS'], 10) : NaN
|
|
if (Number.isFinite(envCols) && envCols > 0) return envCols
|
|
// Conservative fallback. 100 keeps the table readable on the most common
|
|
// terminal sizes (80, 100, 120) without trying to fit cache columns into
|
|
// a window that cannot hold them.
|
|
return 100
|
|
}
|
|
|
|
/// Renders a Unicode box-drawn table. Columns are auto-sized to their content
|
|
/// (with declared `width` as a minimum). When the terminal is narrow, drops
|
|
/// the lowest-priority columns (cache first, then input/output, then top-task)
|
|
/// so the table fits without wrapping.
|
|
export function renderTable(
|
|
rows: ModelReportRow[],
|
|
opts: TableRenderOptions = {},
|
|
): string {
|
|
const byTask = opts.byTask ?? false
|
|
const showTotals = opts.showTotals ?? true
|
|
const available = opts.terminalWidth ?? defaultTerminalWidth()
|
|
const fullWidth = opts.fullWidth ?? true
|
|
// Only render the Saved column when something was actually saved.
|
|
const showSaved = rows.some(r => r.savingsUSD > 0)
|
|
|
|
const valueOf = (row: ModelReportRow, key: Column['key'], isNewGroup: boolean): string => {
|
|
switch (key) {
|
|
case 'provider': return isNewGroup ? row.providerDisplayName : ''
|
|
case 'model': return isNewGroup ? row.modelDisplayName : ''
|
|
case 'task':
|
|
if (byTask) return row.category ? categoryLabel(row.category) : ''
|
|
return row.topCategory
|
|
? `${categoryLabel(row.topCategory)} ${chalk.dim(`(${Math.round((row.topCategoryShare ?? 0) * 100)}%)`)}`
|
|
: chalk.dim('-')
|
|
case 'input': return formatTokens(row.inputTokens)
|
|
case 'output': return formatTokens(row.outputTokens)
|
|
case 'cacheWrite': return formatTokens(row.cacheWriteTokens)
|
|
case 'cacheRead': return formatTokens(row.cacheReadTokens)
|
|
case 'total': return formatTokens(row.totalTokens)
|
|
case 'cost': return formatCost(row.costUSD)
|
|
case 'saved': return row.savingsUSD > 0 ? formatCost(row.savingsUSD) : chalk.dim('-')
|
|
}
|
|
}
|
|
|
|
// Build all cell content first so we can size columns to fit.
|
|
type RowCells = { kind: 'data' | 'totals'; cells: string[]; isNewGroup: boolean }
|
|
const rowEntries: RowCells[] = []
|
|
let prevProviderModel = ''
|
|
for (const row of rows) {
|
|
const groupKey = `${row.provider} ${row.model}`
|
|
const isNewGroup = !byTask || groupKey !== prevProviderModel
|
|
prevProviderModel = groupKey
|
|
const allCells = defaultColumns(byTask, showSaved).map(col => {
|
|
const raw = valueOf(row, col.key, isNewGroup)
|
|
if (col.key === 'provider' && raw) return chalk.dim(raw)
|
|
return raw
|
|
})
|
|
rowEntries.push({ kind: 'data', cells: allCells, isNewGroup })
|
|
}
|
|
|
|
let totalsEntry: RowCells | null = null
|
|
if (showTotals && rows.length > 0) {
|
|
const totals = rows.reduce(
|
|
(acc, r) => {
|
|
acc.input += r.inputTokens
|
|
acc.output += r.outputTokens
|
|
acc.cacheWrite += r.cacheWriteTokens
|
|
acc.cacheRead += r.cacheReadTokens
|
|
acc.total += r.totalTokens
|
|
acc.cost += r.costUSD
|
|
acc.savings += r.savingsUSD
|
|
return acc
|
|
},
|
|
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 },
|
|
)
|
|
const cells = defaultColumns(byTask, showSaved).map(col => {
|
|
switch (col.key) {
|
|
case 'provider': return ''
|
|
case 'model': return chalk.yellow.bold('Total')
|
|
case 'task': return ''
|
|
case 'input': return chalk.yellow(formatTokens(totals.input))
|
|
case 'output': return chalk.yellow(formatTokens(totals.output))
|
|
case 'cacheWrite': return chalk.yellow(formatTokens(totals.cacheWrite))
|
|
case 'cacheRead': return chalk.yellow(formatTokens(totals.cacheRead))
|
|
case 'total': return chalk.yellow.bold(formatTokens(totals.total))
|
|
case 'cost': return chalk.yellow.bold(formatCost(totals.cost))
|
|
case 'saved': return totals.savings > 0 ? chalk.yellow.bold(formatCost(totals.savings)) : chalk.dim('-')
|
|
}
|
|
})
|
|
totalsEntry = { kind: 'totals', cells, isNewGroup: true }
|
|
}
|
|
|
|
// Pick which columns to include based on terminal width, then size them.
|
|
// We index into `cells` by the column key to avoid object-identity pitfalls
|
|
// across defaultColumns() invocations.
|
|
const allKeys = defaultColumns(byTask, showSaved).map(c => c.key)
|
|
const indexByKey = new Map(allKeys.map((k, i) => [k, i]))
|
|
const columns = chooseColumns(byTask, available, showSaved)
|
|
const projectColumns = (cols: Column[], entry: RowCells) =>
|
|
cols.map(c => entry.cells[indexByKey.get(c.key)!] ?? '')
|
|
const cellMatrix = [
|
|
...rowEntries.map(e => projectColumns(columns, e)),
|
|
...(totalsEntry ? [projectColumns(columns, totalsEntry)] : []),
|
|
]
|
|
const sized = sizeColumnsToContent(columns, cellMatrix)
|
|
|
|
// If content sizing pushed the table back over budget, keep dropping the
|
|
// same low-value column groups until the rendered frame fits.
|
|
let final = sized
|
|
if (frameWidth(final) > available) {
|
|
let reduced = columns
|
|
for (const group of DROP_COLUMN_GROUPS) {
|
|
reduced = reduced.filter(c => !group.includes(c.key))
|
|
const reducedMatrix = [
|
|
...rowEntries.map(e => projectColumns(reduced, e)),
|
|
...(totalsEntry ? [projectColumns(reduced, totalsEntry)] : []),
|
|
]
|
|
const candidate = sizeColumnsToContent(reduced, reducedMatrix)
|
|
final = candidate
|
|
if (frameWidth(candidate) <= available) break
|
|
}
|
|
}
|
|
|
|
if (fullWidth && frameWidth(final) < available) {
|
|
final = expandColumnsToWidth(final, available)
|
|
}
|
|
|
|
const lines: string[] = []
|
|
lines.push(renderBorder(final, BOX.topLeft, BOX.topT, BOX.topRight))
|
|
lines.push(renderRow(final.map(c => chalk.cyan.bold(c.header)), final))
|
|
lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT))
|
|
|
|
let isFirstRow = true
|
|
for (const entry of rowEntries) {
|
|
if (byTask && entry.isNewGroup && !isFirstRow) {
|
|
lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT))
|
|
}
|
|
isFirstRow = false
|
|
lines.push(renderRow(projectColumns(final, entry), final))
|
|
}
|
|
|
|
if (totalsEntry) {
|
|
lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT))
|
|
lines.push(renderRow(projectColumns(final, totalsEntry), final))
|
|
}
|
|
|
|
lines.push(renderBorder(final, BOX.bottomLeft, BOX.bottomT, BOX.bottomRight))
|
|
return lines.join('\n')
|
|
}
|
|
|
|
export function renderJson(rows: ModelReportRow[]): string {
|
|
return JSON.stringify(
|
|
rows.map(r => ({
|
|
provider: r.provider,
|
|
providerDisplayName: r.providerDisplayName,
|
|
model: r.model,
|
|
modelDisplayName: r.modelDisplayName,
|
|
category: r.category ?? r.topCategory ?? null,
|
|
topCategory: r.topCategory ?? null,
|
|
topCategoryShare: r.topCategoryShare ?? null,
|
|
inputTokens: r.inputTokens,
|
|
outputTokens: r.outputTokens,
|
|
cacheWriteTokens: r.cacheWriteTokens,
|
|
cacheReadTokens: r.cacheReadTokens,
|
|
totalTokens: r.totalTokens,
|
|
calls: r.calls,
|
|
costUSD: r.costUSD,
|
|
savingsUSD: r.savingsUSD,
|
|
savingsBaselineModel: r.savingsBaselineModel,
|
|
credits: r.credits,
|
|
})),
|
|
null,
|
|
2,
|
|
)
|
|
}
|
|
|
|
function csvEscape(value: string | undefined | null): string {
|
|
if (value === undefined || value === null) return ''
|
|
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
|
return `"${value.replace(/"/g, '""')}"`
|
|
}
|
|
return value
|
|
}
|
|
|
|
function mdEscape(value: string): string {
|
|
// Pipes break GitHub-flavored markdown tables; escape them.
|
|
return value.replace(/\|/g, '\\|')
|
|
}
|
|
|
|
/// GitHub-flavored markdown table. Renders cleanly on GitHub, Notion, and most
|
|
/// chat platforms that understand markdown. Always shows provider/model on
|
|
/// every row (no blank-repeat trick) so the table remains useful when copied
|
|
/// into a context that loses whitespace alignment.
|
|
export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean; showTotals?: boolean } = {}): string {
|
|
const byTask = opts.byTask ?? false
|
|
const showTotals = opts.showTotals ?? true
|
|
|
|
const header = byTask
|
|
? ['Provider', 'Model', 'Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved']
|
|
: ['Provider', 'Model', 'Top Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved']
|
|
const align = ['---', '---', '---', '---:', '---:', '---:', '---:', '---:', '---:', '---:']
|
|
|
|
const lines: string[] = []
|
|
lines.push(`| ${header.join(' | ')} |`)
|
|
lines.push(`| ${align.join(' | ')} |`)
|
|
|
|
for (const row of rows) {
|
|
const taskCell = byTask
|
|
? row.category ? categoryLabel(row.category) : ''
|
|
: row.topCategory
|
|
? `${categoryLabel(row.topCategory)} (${Math.round((row.topCategoryShare ?? 0) * 100)}%)`
|
|
: '-'
|
|
const cells = [
|
|
mdEscape(row.providerDisplayName),
|
|
`\`${mdEscape(row.modelDisplayName)}\``,
|
|
taskCell,
|
|
formatTokens(row.inputTokens),
|
|
formatTokens(row.outputTokens),
|
|
formatTokens(row.cacheWriteTokens),
|
|
formatTokens(row.cacheReadTokens),
|
|
formatTokens(row.totalTokens),
|
|
formatCost(row.costUSD),
|
|
row.savingsUSD > 0 ? formatCost(row.savingsUSD) : '-',
|
|
]
|
|
lines.push(`| ${cells.join(' | ')} |`)
|
|
}
|
|
|
|
if (showTotals && rows.length > 0) {
|
|
const totals = rows.reduce(
|
|
(acc, r) => {
|
|
acc.input += r.inputTokens
|
|
acc.output += r.outputTokens
|
|
acc.cacheWrite += r.cacheWriteTokens
|
|
acc.cacheRead += r.cacheReadTokens
|
|
acc.total += r.totalTokens
|
|
acc.cost += r.costUSD
|
|
acc.savings += r.savingsUSD
|
|
return acc
|
|
},
|
|
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 },
|
|
)
|
|
const totalCells = [
|
|
'',
|
|
'**Total**',
|
|
'',
|
|
`**${formatTokens(totals.input)}**`,
|
|
`**${formatTokens(totals.output)}**`,
|
|
`**${formatTokens(totals.cacheWrite)}**`,
|
|
`**${formatTokens(totals.cacheRead)}**`,
|
|
`**${formatTokens(totals.total)}**`,
|
|
`**${formatCost(totals.cost)}**`,
|
|
totals.savings > 0 ? `**${formatCost(totals.savings)}**` : '-',
|
|
]
|
|
lines.push(`| ${totalCells.join(' | ')} |`)
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean } = {}): string {
|
|
const byTask = opts.byTask ?? false
|
|
// CSV intentionally repeats provider/model on every row so downstream
|
|
// consumers can sort/filter without first reconstructing the grouping.
|
|
const header = byTask
|
|
? ['provider', 'model', 'task', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model']
|
|
: ['provider', 'model', 'top_task', 'top_task_share', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model']
|
|
const lines: string[] = [header.join(',')]
|
|
for (const r of rows) {
|
|
const cells = byTask
|
|
? [
|
|
csvEscape(r.providerDisplayName),
|
|
csvEscape(r.modelDisplayName),
|
|
r.category ? categoryLabel(r.category) : '',
|
|
String(r.inputTokens),
|
|
String(r.outputTokens),
|
|
String(r.cacheWriteTokens),
|
|
String(r.cacheReadTokens),
|
|
String(r.totalTokens),
|
|
String(r.calls),
|
|
r.costUSD.toFixed(6),
|
|
(r.savingsUSD ?? 0).toFixed(6),
|
|
csvEscape(r.savingsBaselineModel),
|
|
]
|
|
: [
|
|
csvEscape(r.providerDisplayName),
|
|
csvEscape(r.modelDisplayName),
|
|
r.topCategory ? categoryLabel(r.topCategory) : '',
|
|
r.topCategoryShare !== undefined ? r.topCategoryShare.toFixed(4) : '',
|
|
String(r.inputTokens),
|
|
String(r.outputTokens),
|
|
String(r.cacheWriteTokens),
|
|
String(r.cacheReadTokens),
|
|
String(r.totalTokens),
|
|
String(r.calls),
|
|
r.costUSD.toFixed(6),
|
|
(r.savingsUSD ?? 0).toFixed(6),
|
|
csvEscape(r.savingsBaselineModel),
|
|
]
|
|
lines.push(cells.join(','))
|
|
}
|
|
return lines.join('\n')
|
|
}
|