mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-02 12:55:04 +00:00
feat(app): Models section — per-model pricing table with series dots, by-model/by-task, unpriced-row alias
Table from getModels(): calls/input/output/cache-read/cost/saved with a model-family series dot (reuses the ListRow helper). Unpriced/proxy rows show the dim "add alias" affordance + dashes. By-model / By-task SegTabs re-fetch (byTask). Compare… is a visual affordance (M2). Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
This commit is contained in:
parent
87f5a9bac1
commit
18b4b728ff
4 changed files with 295 additions and 1 deletions
|
|
@ -9,6 +9,7 @@ import { usePolled } from './hooks/usePolled'
|
|||
import { codeburn } from './lib/ipc'
|
||||
import { Overview } from './sections/Overview'
|
||||
import { Optimize } from './sections/Optimize'
|
||||
import { Models } from './sections/Models'
|
||||
import { Spend } from './sections/Spend'
|
||||
import type { MenubarPayload, Period } from './lib/types'
|
||||
|
||||
|
|
@ -74,6 +75,8 @@ export function App() {
|
|||
<Spend period={period} provider={provider} />
|
||||
) : section === 'optimize' ? (
|
||||
<Optimize period={period} provider={provider} />
|
||||
) : section === 'models' ? (
|
||||
<Models period={period} provider={provider} />
|
||||
) : (
|
||||
<SectionPlaceholder title={SECTION_TITLES[section]} />
|
||||
)}
|
||||
|
|
|
|||
139
app/renderer/sections/Models.test.tsx
Normal file
139
app/renderer/sections/Models.test.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// @vitest-environment jsdom
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ModelReportRow } from '../lib/types'
|
||||
import { Models } from './Models'
|
||||
|
||||
const { getModels } = vi.hoisted(() => ({
|
||||
getModels: vi.fn<(period: string, provider: string, byTask: boolean) => Promise<ModelReportRow[]>>(),
|
||||
}))
|
||||
vi.mock('../lib/ipc', async orig => {
|
||||
const actual = await orig<typeof import('../lib/ipc')>()
|
||||
return { ...actual, codeburn: { getModels } }
|
||||
})
|
||||
|
||||
const rows: ModelReportRow[] = [
|
||||
{
|
||||
provider: 'anthropic',
|
||||
providerDisplayName: 'Anthropic',
|
||||
model: 'claude-opus-4.8',
|
||||
modelDisplayName: 'Claude Opus 4.8',
|
||||
category: null,
|
||||
topCategory: 'coding',
|
||||
topCategoryShare: 0.71,
|
||||
inputTokens: 152_600_000,
|
||||
outputTokens: 9_640_000,
|
||||
cacheWriteTokens: 16_000_000,
|
||||
cacheReadTokens: 119_400_000,
|
||||
totalTokens: 297_640_000,
|
||||
calls: 4812,
|
||||
costUSD: 331.2,
|
||||
savingsUSD: 86.4,
|
||||
savingsBaselineModel: 'Claude Opus 4.8',
|
||||
credits: null,
|
||||
},
|
||||
{
|
||||
provider: 'openai',
|
||||
providerDisplayName: 'OpenAI',
|
||||
model: 'gpt-5.5-codex',
|
||||
modelDisplayName: 'GPT-5.5 Codex',
|
||||
category: null,
|
||||
topCategory: 'debugging',
|
||||
topCategoryShare: 0.42,
|
||||
inputTokens: 86_900_000,
|
||||
outputTokens: 7_520_000,
|
||||
cacheWriteTokens: 3_200_000,
|
||||
cacheReadTokens: 45_100_000,
|
||||
totalTokens: 142_720_000,
|
||||
calls: 2704,
|
||||
costUSD: 137.9,
|
||||
savingsUSD: 35.1,
|
||||
savingsBaselineModel: 'GPT-5.5 Codex',
|
||||
credits: null,
|
||||
},
|
||||
{
|
||||
provider: 'custom',
|
||||
providerDisplayName: 'Custom',
|
||||
model: 'my-proxy-model',
|
||||
modelDisplayName: 'my-proxy-model',
|
||||
category: null,
|
||||
inputTokens: 4_800_000,
|
||||
outputTokens: 400_000,
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalTokens: 5_200_000,
|
||||
calls: 176,
|
||||
costUSD: 0,
|
||||
savingsUSD: 0,
|
||||
savingsBaselineModel: '',
|
||||
credits: null,
|
||||
},
|
||||
]
|
||||
|
||||
const byTaskRows: ModelReportRow[] = [
|
||||
{
|
||||
...rows[0],
|
||||
category: 'coding',
|
||||
calls: 3400,
|
||||
inputTokens: 100_000_000,
|
||||
outputTokens: 6_100_000,
|
||||
cacheReadTokens: 88_000_000,
|
||||
totalTokens: 210_100_000,
|
||||
costUSD: 244.12,
|
||||
savingsUSD: 61.22,
|
||||
},
|
||||
]
|
||||
|
||||
describe('Models', () => {
|
||||
beforeEach(() => {
|
||||
getModels.mockReset()
|
||||
})
|
||||
|
||||
it('renders priced model rows with series dots, costs, and savings', async () => {
|
||||
getModels.mockResolvedValue(rows)
|
||||
|
||||
const { container } = render(<Models period="30days" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument()
|
||||
expect(screen.getByText('4,812')).toBeInTheDocument()
|
||||
expect(screen.getByText('152.6M')).toBeInTheDocument()
|
||||
expect(screen.getByText('9.64M')).toBeInTheDocument()
|
||||
expect(screen.getByText('119.4M')).toBeInTheDocument()
|
||||
expect(screen.getByText('$331.20')).toBeInTheDocument()
|
||||
expect(screen.getByText('$86.40')).toHaveClass('pos')
|
||||
expect(screen.getByText('GPT-5.5 Codex')).toBeInTheDocument()
|
||||
expect(screen.getByText('$137.90')).toBeInTheDocument()
|
||||
expect(screen.getByText('$35.10')).toHaveClass('pos')
|
||||
|
||||
const dots = [...container.querySelectorAll('.mdot')]
|
||||
expect(dots[0]).toHaveAttribute('style', expect.stringContaining('var(--blue)'))
|
||||
expect(dots[1]).toHaveAttribute('style', expect.stringContaining('var(--cyan)'))
|
||||
})
|
||||
|
||||
it('renders unpriced proxy rows as dim with alias affordance and cost dashes', async () => {
|
||||
getModels.mockResolvedValue(rows)
|
||||
|
||||
render(<Models period="30days" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('my-proxy-model')).toHaveClass('dim')
|
||||
expect(screen.getByText('add alias ›')).toHaveClass('alias')
|
||||
expect(screen.getAllByText('—')).toHaveLength(2)
|
||||
expect(screen.queryByText('$0.00')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refetches with byTask=true and renders the task category', async () => {
|
||||
getModels.mockResolvedValueOnce(rows).mockResolvedValueOnce(byTaskRows)
|
||||
|
||||
render(<Models period="week" provider="anthropic" />)
|
||||
|
||||
expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument()
|
||||
expect(getModels).toHaveBeenCalledWith('week', 'anthropic', false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'By task' }))
|
||||
|
||||
await waitFor(() => expect(getModels).toHaveBeenCalledWith('week', 'anthropic', true))
|
||||
expect(await screen.findByText('coding')).toBeInTheDocument()
|
||||
expect(screen.getByText('$244.12')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
152
app/renderer/sections/Models.tsx
Normal file
152
app/renderer/sections/Models.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { seriesColorForModel } from '../components/ListRow'
|
||||
import { Panel } from '../components/Panel'
|
||||
import { SegTabs } from '../components/SegTabs'
|
||||
import { usePolled } from '../hooks/usePolled'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import type { ModelReportRow, Period } from '../lib/types'
|
||||
|
||||
type ModelsLens = 'model' | 'task'
|
||||
|
||||
const LENSES = [
|
||||
{ value: 'model', label: 'By model' },
|
||||
{ value: 'task', label: 'By task' },
|
||||
]
|
||||
|
||||
function fmtUsd(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function fmtInt(n: number): string {
|
||||
return n.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
function fmtCompact(n: number): string {
|
||||
if (n === 0) return '0'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: n >= 10_000_000 ? 1 : 2,
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
function EmptyNote({ children }: { children: React.ReactNode }) {
|
||||
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
|
||||
}
|
||||
|
||||
export function Models({ period, provider }: { period: Period; provider: string }) {
|
||||
const [lens, setLens] = useState<ModelsLens>('model')
|
||||
const byTask = lens === 'task'
|
||||
const report = usePolled<ModelReportRow[]>(
|
||||
() => codeburn.getModels(period, provider, byTask),
|
||||
[period, provider, byTask],
|
||||
)
|
||||
|
||||
if (!report.data) {
|
||||
if (report.error?.kind === 'not-found') {
|
||||
return (
|
||||
<Panel title="Locate the codeburn CLI">
|
||||
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
|
||||
CodeBurn Desktop reads your model usage by running the{' '}
|
||||
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it isn't
|
||||
on your PATH yet.
|
||||
</p>
|
||||
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
|
||||
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
|
||||
then reopen this window.
|
||||
</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
if (report.error) {
|
||||
return (
|
||||
<Panel title="Couldn't read models">
|
||||
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{report.error.message}</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Panel title="Models">
|
||||
<EmptyNote>Scanning model usage…</EmptyNote>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'flex-start' }}>
|
||||
<SegTabs options={LENSES} value={lens} onChange={value => setLens(value as ModelsLens)} />
|
||||
<span className="btn btn-s" aria-disabled="true">
|
||||
Compare…
|
||||
</span>
|
||||
</div>
|
||||
<Panel bodyStyle={{ overflowX: 'auto' }}>
|
||||
{report.data.length ? (
|
||||
<ModelsTable rows={report.data} byTask={byTask} />
|
||||
) : (
|
||||
<EmptyNote>No model usage in this range yet.</EmptyNote>
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelsTable({ rows, byTask }: { rows: ModelReportRow[]; byTask: boolean }) {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Calls</th>
|
||||
<th>Input</th>
|
||||
<th>Output</th>
|
||||
<th>Cache read</th>
|
||||
<th>Cost</th>
|
||||
<th>Saved</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<ModelTableRow key={`${row.provider}-${row.model}-${row.category ?? 'all'}-${i}`} row={row} byTask={byTask} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelTableRow({ row, byTask }: { row: ModelReportRow; byTask: boolean }) {
|
||||
const unpriced = (row.costUSD === 0 && row.calls > 0) || row.credits !== null
|
||||
const cellClass = unpriced ? 'dim' : undefined
|
||||
const dotStyle = {
|
||||
display: 'inline-block',
|
||||
background: seriesColorForModel(row.modelDisplayName || row.model),
|
||||
marginRight: 8,
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className={cellClass} title={row.model}>
|
||||
<span className="mdot" style={dotStyle} />
|
||||
{row.modelDisplayName}
|
||||
{byTask && row.category ? (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="est">{row.category}</span>
|
||||
</>
|
||||
) : null}
|
||||
{unpriced ? (
|
||||
<>
|
||||
{' '}
|
||||
<span className="alias">add alias ›</span>
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
<td className={cellClass}>{fmtInt(row.calls)}</td>
|
||||
<td className={cellClass}>{fmtCompact(row.inputTokens)}</td>
|
||||
<td className={cellClass}>{fmtCompact(row.outputTokens)}</td>
|
||||
<td className={cellClass}>{fmtCompact(row.cacheReadTokens)}</td>
|
||||
<td className={cellClass}>{unpriced ? '—' : fmtUsd(row.costUSD)}</td>
|
||||
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : fmtUsd(row.savingsUSD)}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
|
@ -149,7 +149,7 @@ th:last-child, td:last-child { padding-right: 2px; }
|
|||
td { font-family: var(--mono); font-size: 11.5px; text-align: right; padding: 7px 10px; border-bottom: 1px solid var(--hair); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
td:first-child { font-family: var(--sans); font-size: 12.5px; font-weight: 520; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
td .pos { color: var(--mint); }
|
||||
td.pos, td .pos { color: var(--mint); }
|
||||
.dim { color: var(--t3); }
|
||||
.est { font-size: 9.5px; color: var(--t3); font-family: var(--mono); }
|
||||
.alias { color: var(--lav); font-family: var(--sans); font-size: 11px; font-weight: 530; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue