AI cost/ROI page: pricing table, cost per call, Cost tab, ROI endpoint

- templates/web/pricing.py: pricing table with source and as-of date; cost_for_call
  (input rate + (output + thinking) at output rate, long-context above threshold;
  unknown model raises naming the known models).
- Cost tab: coverage pill, total/this-month/tokens tiles, month-by-month table,
  per-run tokens and cost ("not priced", never $0), by-hand panel with editable
  assumptions labelled as such.
- Contract: RoiRun and GET /roi; optional GET /models and POST /runs/{id}/retry.
- SKILL.md: cost page wiring, model picker and retry-on-other-model as optional sections.
- Tests: test_pricing.py self-check, page_check_ai/test_ai_cost.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 15:23:52 +05:30
1 parent 8951ac05a5
commit 9dcdd8f572
11 files changed
+392 -6

No files matched your search

@@ -4,12 +4,14 @@ import { Overview } from './components/Overview'
import { RunPanel } from './components/RunPanel'
import { Report } from './components/Report'
import { Review } from './components/Review'
import { Cost } from './components/Cost'
const TABS = {
dashboard: ['Dashboard', 'Overview of every batch', <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></>],
run: ['Run', 'Live progress and log', <polygon points="6 4 20 12 6 20 6 4" />],
review: ['Review', 'Check extracted fields against the document', <><path d="M4 4h10l6 6v10H4z" /><path d="m8 14 3 3 5-6" /></>],
report: ['Report', 'Result of every processed item', <><path d="M4 4h16v16H4z" /><path d="M8 9h8M8 13h8M8 17h5" /></>],
cost: ['Cost', 'Tokens, spend and the case against doing it by hand', <><path d="M12 2v20" /><path d="M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>],
// OPTIONAL: admin/roles tab goes here
} as const
type Tab = keyof typeof TABS
@@ -98,6 +100,7 @@ export function App() {
{tab === 'run' && <RunPanel />}
{tab === 'review' && <Review onUnsaved={setUnsaved} />}
{tab === 'report' && <Report />}
{tab === 'cost' && <Cost />}
</section>
</main>
</div>
+14
View File
@@ -23,6 +23,19 @@ export type ReviewRow = {
corrected_value: string | null
}
/** One run on the cost page. total_cost is null when the run recorded no token usage (unpriced). */
export type RoiRun = {
job_id: string
document: string
created_at: string // ISO, UTC
model: string
input_tokens: number | null
output_tokens: number | null
thinking_tokens: number | null
total_cost: number | null // USD
}
export type Pricing = { source: string; as_of: string }
export const BUSY: RunStatus[] = ['queued', 'running'] // polled every 1.5s while in one of these
/** Legend order, label, and the colour token. Band colours are semantic: never on buttons or headings. */
@@ -53,5 +66,6 @@ export const ai = {
req<ReviewRow>(`/review/${rowId}`, { method: 'PATCH', body: JSON.stringify(body) }),
verifyRemaining: (id: string) => req<{ verified: number }>(`/runs/${id}/verify-remaining`, { method: 'POST' }),
approve: (id: string) => req<{ ok: boolean }>(`/runs/${id}/approve`, { method: 'POST' }),
roi: () => req<{ runs: RoiRun[]; pricing: Pricing }>('/roi'),
documentUrl: (id: string) => `${API_BASE}/runs/${id}/document`,
}
@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react'
import { ai, type Pricing, type RoiRun } from '../ai'
const money = (n: number) => `$${n.toFixed(n > 0 && n < 0.01 ? 4 : 2)}`
const num = (n: number | null) => (n == null ? '—' : n.toLocaleString('en-US'))
const monthOf = (iso: string) => iso.slice(0, 7) // 'YYYY-MM', UTC
const monthLabel = (ym: string) =>
new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })
/**
* The cost page. Two kinds of number, kept apart:
* measured: tokens and cost per run, priced by the backend's pricing table;
* assumed: minutes per document and an hourly rate, typed by the reader and labelled so.
* A run with no recorded token usage is "not priced", never $0, and the coverage pill says how many.
*/
export function Cost() {
const [runs, setRuns] = useState<RoiRun[]>([])
const [pricing, setPricing] = useState<Pricing>()
const [error, setError] = useState<string>()
const [minutes, setMinutes] = useState(5) // assumption: minutes to key one document by hand
const [rate, setRate] = useState(20) // assumption: USD per hour of that person's time
useEffect(() => {
ai.roi().then((d) => { setRuns(d.runs); setPricing(d.pricing) }).catch((e) => setError(e.message))
}, [])
const priced = runs.filter((r) => r.total_cost != null)
const sum = (rs: RoiRun[]) => rs.reduce((n, r) => n + (r.total_cost ?? 0), 0)
const thisMonth = new Date().toISOString().slice(0, 7)
const months = [...new Set(runs.map((r) => monthOf(r.created_at)))].sort().reverse()
.map((m) => {
const rs = runs.filter((r) => monthOf(r.created_at) === m)
return { m, docs: rs.length, priced: rs.filter((r) => r.total_cost != null).length, cost: sum(rs) }
})
const tokens = (k: 'input_tokens' | 'output_tokens' | 'thinking_tokens') => priced.reduce((n, r) => n + (r[k] ?? 0), 0)
const byHand = (priced.length * minutes / 60) * rate
if (error) return <div className="card text-bad">Can't load costs from {API_BASE}/roi: {error}</div>
return (
<>
<div className="mb-4 flex flex-wrap items-center gap-3">
<span className={`pill ${priced.length === runs.length ? 'bg-ok' : 'bg-warn'}`}>
{priced.length} of {runs.length} documents priced
</span>
{pricing && (
<span className="text-[11.5px] text-muted">
Rates from <a className="underline" href={pricing.source} target="_blank" rel="noreferrer">{pricing.source}</a>, as of {pricing.as_of}
</span>
)}
</div>
<div className="mb-4 grid grid-cols-[repeat(auto-fit,minmax(170px,1fr))] gap-4">
<Tile name="Total spend" value={money(sum(priced))} note={`${priced.length} priced runs`} />
<Tile name="This month" value={money(sum(runs.filter((r) => monthOf(r.created_at) === thisMonth)))} note={monthLabel(thisMonth)} />
<Tile name="Tokens" value={num(tokens('input_tokens') + tokens('output_tokens') + tokens('thinking_tokens'))}
note={`in ${num(tokens('input_tokens'))} · out ${num(tokens('output_tokens'))} · thinking ${num(tokens('thinking_tokens'))}`} />
<Tile name="Per document" value={priced.length ? money(sum(priced) / priced.length) : '—'} note="average, priced runs" />
</div>
<div className="card">
<div className="card-title">Month by month</div>
<div className="overflow-auto">
<table aria-label="Spend by month" className="w-full border-collapse whitespace-nowrap text-xs">
<thead><tr><Th>Month</Th><Th>Documents priced</Th><Th right>Spend</Th></tr></thead>
<tbody>
{months.map((x) => (
<tr key={x.m} className="border-t border-line">
<Td>{monthLabel(x.m)}{x.m === thisMonth && <span className="ml-2 text-muted">this month</span>}</Td>
<Td>{x.priced} of {x.docs}</Td>
<Td right>{x.priced ? money(x.cost) : '—'}</Td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<section className="card" aria-labelledby="by-hand">
<div id="by-hand" className="card-title">Against doing it by hand</div>
<p className="mb-3 text-xs text-muted">These are your assumptions, not a measurement. Change them to match the team.</p>
<div className="mb-3 flex flex-wrap gap-4 text-xs">
<label className="flex items-center gap-2">Minutes per document
<input className="input w-20" type="number" min="0" value={minutes} onChange={(e) => setMinutes(+e.target.value)} />
</label>
<label className="flex items-center gap-2">Hourly rate
<input className="input w-20" type="number" min="0" value={rate} onChange={(e) => setRate(+e.target.value)} />
</label>
</div>
<p className="text-[13px]">
By hand, {priced.length} documents would take {num(Math.round(priced.length * minutes / 6) / 10)} h, about <b>{money(byHand)}</b>.
The AI spent <b>{money(sum(priced))}</b> on them.
</p>
</section>
{/* OPTIONAL: model picker (per batch) goes here */}
<div className="card">
<div className="card-title">Cost per run</div>
<div className="max-h-[50vh] overflow-auto">
<table aria-label="Cost per run" className="w-full border-collapse whitespace-nowrap text-xs">
<thead>
<tr><Th>Document</Th><Th>Date</Th><Th>Model</Th><Th right>Input</Th><Th right>Output</Th><Th right>Thinking</Th><Th right>Cost</Th></tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.job_id} className="border-t border-line">
<Td>{r.document}</Td><Td>{r.created_at.slice(0, 10)}</Td><Td>{r.model}</Td>
<Td right>{num(r.input_tokens)}</Td><Td right>{num(r.output_tokens)}</Td><Td right>{num(r.thinking_tokens)}</Td>
<Td right>{r.total_cost == null ? <span className="text-muted">not priced</span> : money(r.total_cost)}</Td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)
}
function Tile({ name, value, note }: { name: string; value: string; note: string }) {
return (
<div role="group" aria-label={name} className="card mb-0">
<div className="text-[11px] font-bold uppercase tracking-wide text-muted">{name}</div>
<div className="mt-1 font-head text-[26px] font-extrabold">{value}</div>
<div className="mt-1 text-[11px] text-muted">{note}</div>
</div>
)
}
const Th = ({ children, right }: { children: React.ReactNode; right?: boolean }) =>
<th className={`px-3 py-2 font-bold ${right ? 'text-right' : 'text-left'}`}>{children}</th>
const Td = ({ children, right }: { children: React.ReactNode; right?: boolean }) =>
<td className={`px-3 py-2 ${right ? 'text-right' : ''}`}>{children}</td>
@@ -105,6 +105,7 @@ export function Review({ onUnsaved }: { onUnsaved: (n: number) => void }) {
{run && <span className={`pill ${run.status === 'done' ? 'bg-ok' : run.status === 'failed' ? 'bg-bad' : 'bg-warn'}`}>{run.status}</span>}
{run && BUSY.includes(run.status) && <span className="text-[12.5px] text-muted tabular-nums">page {run.pages_done} of {run.pages_total || '?'}</span>}
{run?.error && <span className="text-[12px] text-bad">{run.error}</span>}
{/* OPTIONAL: retry on another model (failed runs) goes here */}
{run?.status === 'done' && (
<div className="ml-auto flex items-center gap-2">
<span className="text-[12px] text-muted">{left} to verify</span>