diff --git a/README.md b/README.md index d580e6b..539dce0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # agent-atlas Provider-agnostic cost & agent-tree explorer for AI coding sessions. -**React 19 + Vite UI**, backed by a Node 22+ server. No runtime outbound network, no telemetry, -and binds `127.0.0.1` by default. +**React 19 + Vite UI**, backed by a Node 22+ server. No telemetry, and binds `127.0.0.1` by +default. The only outbound request is a daily read of the vendors' public price pages +(see [Pricing](#pricing)); `AGENT_ATLAS_PRICING=offline` turns it off. Reads the transcripts your agent harness already writes, builds a nested tree of every agent that ran (main → subagents → their subagents, any depth), @@ -96,10 +97,9 @@ Implemented: process, the first time the store is opened. `NODE_NO_WARNINGS=1` silences it for `--json` pipelines. On a host that never ran Codex, nothing is read, no warning is printed, and nothing about Claude sessions changes. - Note the index's default filters hide cheap sessions — `min $` ships ticked at - `0.5` over a 30-day window — so a Codex session on a model with no price row - (cost `0`, `n/a`) does not appear on `/` until you untick it. Pre-existing and - provider-neutral, but worth knowing before concluding a session is missing. + A session whose cost is not fully known (`n/a` or `partial`, see + [Pricing](#pricing)) is never hidden by the index's `min $` filter: a threshold + cannot rule out a figure that is unknown. Planned (design notes, not yet implemented): @@ -112,8 +112,8 @@ which is why it is the only mechanism here: when a Bash `tool_use` whose command mentions `codex` returns a `tool_result` carrying codex's own `session id: ` line, agent-atlas emits one `CLI:codex` leaf per distinct uuid per tree, under the agent whose transcript it was read from. -It is a stub — `cost` is always `0` / `n/a`, since no Codex price table -exists — and the banner fields it carries (`tokensUsed`, `sandbox`, +It is a stub — its own `cost` is `n/a` (the banner carries no per-call usage) +until the codex provider replaces it with the real thread — and the banner fields it carries (`tokensUsed`, `sandbox`, `workdir`, `approval`, `codexVersion`, under `reported`) are harvested only from a complete printed banner; a grep or log re-read yields the session id and nothing else. **Those `reported.*` key names are provisional**, and may be @@ -125,18 +125,68 @@ tool call carries no command to anchor on). Neither leaves a trace in the tree. ## Pricing -`src/prices.mjs`. Anthropic list prices with cache multipliers (0.1× read, -1.25× 5m write, 2× 1h write) **validated to 8 decimals against the harness's -own `total_cost_usd`** for `haiku-4-5` and `opus-5`; other models use the same -multipliers and are flagged `computed`. Sonnet 5's introductory window -(≤ 2026-08-31) is handled. - -The same file carries a `CODEX` table of OpenAI list prices for the Codex -provider, sourced and dated in its own header comment. All three rows are -`computed` — Codex exposes no `total_cost_usd` to validate against. `gpt-5.6-sol`'s -rate is **promotional through ~2026-11-21** and no post-window rate is published, -so no time-window branch is built for it: the far side would be a guess. A Codex -model with no row prices `n/a`, never `$0.00`. +No price is written in the source. `src/pricing/` resolves every API call's price at +runtime from the vendors' own published price pages, and every figure carries where it +came from. + +**Sources.** The official pages, read as the Markdown each vendor serves next to the +HTML page: + +| Vendor (harness) | Source | +|---|---| +| Anthropic (Claude Code) | — the *Model pricing* table: input, 5m/1h cache writes, cache hits, output | +| OpenAI (Codex) | — the *Standard* table, short- and long-context (> 272K input tokens per request) halves | + +`src/pricing/sources.mjs` holds the two parsers. They are strict: a page whose table +cannot be read, or that yields fewer than five priced rows, is a failed refresh, never +a price. A rate a page does not publish (e.g. cache writes on `gpt-5.5`) stays +unknown — never derived from a multiplier. + +**Ledger, and why history does not move.** Prices live in an append-only ledger of +dated entries, merged from two files: + +- `src/pricing/snapshot.json` — bundled, the last-known prices, written by + `npm run prices:snapshot` from the same sources and parsers. It is what an offline + host prices with. Refresh it before a release and review the diff: entries are only + appended, so the diff is exactly the prices that changed. +- the local cache — prices observed live, at `$XDG_CACHE_HOME/agent-atlas/prices.json` + (`~/.cache/…` by default; override with `AGENT_ATLAS_PRICES_CACHE`). + +A refresh that sees a different rate appends an entry effective **from the moment it +was observed**; a call made earlier keeps the price it had. A model's first entry +applies back to its first use, because no page says when a price started. A failed or +malformed refresh appends nothing, and a model that disappears from a page keeps its +entries. + +**Refresh and cache policy.** Each source is re-read at most once every 24 h, a failed +one is retried after an hour, and nothing is fetched while the cache is fresh. +`serve` refreshes in the background of its index worker and never waits for it; +`list`/`tree`/`export` give a due refresh up to 2 s per source and then carry on. A +source that is down, slow or redesigned never blocks session discovery: pricing just +uses the cache and the snapshot. `AGENT_ATLAS_PRICING=offline` disables the network +entirely. + +**Confidence.** Each node's `cost.confidence` (subtree) and `cost.ownConfidence`: + +- `verified` — list price × tokens, with the arithmetic reproduced to 8 decimals + against the harness's own `total_cost_usd` (`claude-opus-5`, `claude-haiku-4-5`, + 2026-09-01; the flag is on those ledger entries and does not carry to a new price). +- `computed` — list price × tokens from an official source, not independently checked. + Codex has no `total_cost_usd` to check against, so Codex is always `computed`. +- `partial` — some calls, or some token class, had no price; the figure is a **lower + bound** and renders as `≥ $x`. +- `n/a` — nothing was priced (e.g. `codex-auto-review`, which no page lists). It + renders as `n/a`, **never `$0.00`**, and an unpriced call's `cost` is `null`. + +**Provenance.** `cost.provenance` lists every ledger entry the node's own calls were +priced with (`vendor`, `model`, `currency`, per-class `rates`, `long`-context rates, +`from`, `observedAt`, `source.kind` bundled/live and `source.url`), and each call +carries the `price` id of its entry. + +Not modelled: Batch/Flex/Fast (priority) tiers and data-residency uplifts — the +transcripts do not record them in a form priced here — and `gpt-5.6-sol`'s rate is the +published promotional one (through at least 2026-11-21); when OpenAI changes it, the +next refresh records the new rate from that moment on. ## Caveats diff --git a/package.json b/package.json index e1031a5..f9ecf65 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "typecheck": "tsc --noEmit", "bench": "node bench/sessions.mjs", "test:browser": "node --test test/browser.test.mjs", - "pretest:browser": "npm run build" + "pretest:browser": "npm run build", + "prices:snapshot": "node scripts/prices-snapshot.mjs" }, "dependencies": { "@tanstack/react-query": "^5.103.2", diff --git a/scripts/prices-snapshot.mjs b/scripts/prices-snapshot.mjs new file mode 100644 index 0000000..d4270e0 --- /dev/null +++ b/scripts/prices-snapshot.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +// Refresh the bundled price snapshot (src/pricing/snapshot.json) from the live +// sources, with the same parsers and the same append-only merge the runtime +// uses. Run it before a release and review the diff: an entry is only ever +// appended, so the diff shows exactly which prices changed and since when. +// +// npm run prices:snapshot # fetch every source +// npm run prices:snapshot -- anthropic=page.md # read a saved page instead +import fs from "node:fs"; +import { SOURCES } from "../src/pricing/sources.mjs"; +import { snapshotFile, readStore, writeStore, build, observe } from "../src/pricing/index.mjs"; + +const files = Object.fromEntries(process.argv.slice(2).map(a => a.split("="))); +const snapshot = readStore(snapshotFile); +const observedAt = new Date().toISOString(); +let failed = 0; +for (const source of SOURCES) { + try { + const text = files[source.vendor] ? fs.readFileSync(files[source.vendor], "utf8") + : await (async () => { const r = await fetch(source.url); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); })(); + const rows = source.parse(text); + const added = observe(snapshot, build(snapshot, { entries: [] }).byKey, source, rows, observedAt); + snapshot.sources[source.vendor] = { page: source.page, url: source.url, retrievedAt: observedAt, models: rows.length }; + console.log(`${source.vendor}: ${rows.length} models, ${added} new entr${added === 1 ? "y" : "ies"}`); + } catch (e) { failed++; console.error(`${source.vendor}: ${e.message}`); } +} +writeStore(snapshotFile, snapshot); +process.exit(failed ? 1 : 0); diff --git a/src/cli.mjs b/src/cli.mjs index f62623c..8df2a81 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -5,7 +5,8 @@ import * as claude from "./providers/claude.mjs"; import { describe, workspace } from "./providers/claude.mjs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { treeTerminal, fmtDur } from "./render.mjs"; +import { treeTerminal, fmtDur, costLabel } from "./render.mjs"; +import { refreshPrices } from "./pricing/index.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const exportHTML = tree => { @@ -24,6 +25,11 @@ const opt = (name, dflt) => { }; const LIVE_MS = 120_000; +// A one-shot command gives a due price refresh a short, bounded chance before it +// prices anything; a slow or failed source just leaves the bundled/cached prices. +// Nothing is fetched while the local cache is fresh (see pricing/index.mjs). +if (["list", "tree", "export"].includes(cmd)) await refreshPrices({ timeoutMs: 2000 }).catch(() => {}); + function resolveSession(ref) { if (fs.existsSync(ref)) return ref; const hits = claude.discover().filter(s => s.id.startsWith(ref)); @@ -49,7 +55,7 @@ if (cmd === "list") { name: describe(t).title || t.identity?.agentName || "", desc: describe(t).subtitle.slice(0, 60), start: t.start, durationS: t.durationS, live: Date.now() - s.mtimeMs < LIVE_MS, - agents: countAgents(t), cost: t.cost.total }; + agents: countAgents(t), cost: t.cost.total, costConfidence: t.cost.confidence }; }) .filter(r => r.cost >= minCost); if (sortK === "cost") rows.sort((a, b) => b.cost - a.cost); @@ -57,7 +63,7 @@ if (cmd === "list") { for (const r of rows) console.log([r.live ? "LIVE" : " ", r.id.slice(0, 8), (r.start || "").slice(0, 16).replace("T", " "), fmtDur(r.durationS).padStart(6), - String(r.agents).padStart(3) + " ag", ("$" + r.cost.toFixed(2)).padStart(9), + String(r.agents).padStart(3) + " ag", costLabel(r.cost, r.costConfidence).padStart(9), r.name || r.project, r.desc ? "· " + r.desc : ""].join(" ")); const total = rows.reduce((a, r) => a + r.cost, 0); console.log(`\n${rows.length} sessions · total $${total.toFixed(2)} (last ${all ? "∞" : days + "d"})`); diff --git a/src/index-worker.mjs b/src/index-worker.mjs index cd33e0e..2ee2a24 100644 --- a/src/index-worker.mjs +++ b/src/index-worker.mjs @@ -4,6 +4,12 @@ import { parentPort } from "node:worker_threads"; import * as providers from "./providers/index.mjs"; import { describe, workspace } from "./providers/index.mjs"; import { LIVE_MS, toSessionRow } from "./session-index.mjs"; +import { refreshPrices, pricingRevision } from "./pricing/index.mjs"; + +// Prices refresh in the background and are never awaited: a scan prices with +// whatever the ledger holds at that moment. refreshPrices() is a no-op until a +// source is due again (daily), so calling it on every scan costs nothing. +const refresh = () => { refreshPrices().catch(() => {}); }; const parserCache = new Map(); const summaries = new Map(); @@ -19,19 +25,22 @@ function fingerprint(file) { visit(file); visit(sessionFolder(file)); return { stamp: entries.join("|"), files }; } function scan() { + refresh(); const now = Date.now(); const sessions = providers.discover().sort((a, b) => b.mtimeMs - a.mtimeMs); const keyOf = session => `${session.provider}:${session.id}`; const active = new Set(sessions.map(keyOf)), activeFiles = new Set(); for (const id of summaries.keys()) if (!active.has(id)) summaries.delete(id); const rows = sessions.map(session => { - const key = keyOf(session), fingerprinted = fingerprint(session.path); fingerprinted.files.forEach(file => activeFiles.add(file)); const stamp = fingerprinted.stamp || `${session.mtimeMs}:${session.size}`; const previous = summaries.get(key); + const key = keyOf(session), fingerprinted = fingerprint(session.path); fingerprinted.files.forEach(file => activeFiles.add(file)); const stamp = (fingerprinted.stamp || `${session.mtimeMs}:${session.size}`) + "|prices:" + pricingRevision(); const previous = summaries.get(key); if (previous && previous.path === session.path && previous.stamp === stamp) return { ...previous.row, live: now - session.mtimeMs < LIVE_MS }; const row = toSessionRow(session, providers.buildTree(session, parserCache), now); summaries.set(key, { ref: session, path: session.path, stamp, row }); return row; }); for (const key of parserCache.keys()) if (!activeFiles.has(key)) parserCache.delete(key); - const signature = rows.map(row => row.id + ":" + (summaries.get(row.id)?.stamp || "") + ":" + Number(row.live)).join("|"); + // Keyed like summaries (provider:id): looking a row up by its bare id always + // missed, so a re-priced or grown session never registered as a change. + const signature = sessions.map((session, i) => keyOf(session) + ":" + (summaries.get(keyOf(session))?.stamp || "") + ":" + Number(rows[i].live)).join("|"); const changed = signature !== lastSignature; if (changed) { lastSignature = signature; lastRows = rows; } return { rows: changed ? rows : lastRows, scannedAt: now, changed }; } diff --git a/src/main.tsx b/src/main.tsx index ca53b64..6927ad4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider, useQueries, useQuery, useQueryClient import { useWindowVirtualizer } from "@tanstack/react-virtual"; import "./ui.css"; import { SessionDetail } from "./ui/session-view"; +import { money, costText, unpricedTitle } from "./ui/money"; const client = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000, retry: 1, refetchOnWindowFocus: false } } }); const token = new URLSearchParams(location.search).get("t"); @@ -15,7 +16,6 @@ const api = async (pathname, params = {}) => { if (!response.ok) { const error = new Error(((await response.json().catch(() => ({}))) as { error?: string }).error || response.statusText) as Error & { status?: number }; error.status = response.status; if (response.status === 409) window.dispatchEvent(new Event("atlas-revision-expired")); throw error; } return response.json(); }; -const money = value => value == null ? "—" : value > 0 && value < .01 ? "\$" + value.toFixed(4) : "\$" + value.toFixed(2); const num = value => Number(value || 0).toLocaleString("en"); const duration = value => value == null ? "" : value >= 3600 ? (value / 3600).toFixed(1) + "h" : value >= 60 ? Math.round(value / 60) + "m" : value + "s"; const tokenCount = value => value >= 1e6 ? (value / 1e6).toFixed(1) + "M" : value >= 1e3 ? (value / 1e3).toFixed(0) + "k" : num(value); @@ -41,7 +41,8 @@ function writeFilters(filters) { function MetricGrid({ summary }) { const token = summary.token || {}, tokenCost = summary.tokenCost || {}; - const items = [["total cost", money(summary.cost), num(summary.sessions) + " sessions"], ["avg / session", money(summary.average), "median " + money(summary.median)], ["human messages", num(summary.humanMsgs), money(summary.costPerHumanMsg) + "/msg · " + (summary.callsPerHumanMsg || 0).toFixed(1) + " calls/msg"], ["agents", num(summary.agents), num(summary.calls) + " API calls · " + duration(Math.round(summary.agentMs / 1000)) + " agent time"], ["input", tokenCount(token.in), money(tokenCost.in)], ["output", tokenCount(token.out), money(tokenCost.out)], ["cache read", tokenCount(token.cr), money(tokenCost.cr)], ["cache write", tokenCount(token.cw), money(tokenCost.cw)]]; + const bound = summary.unpriced ? "partial" : undefined; + const items = [["total cost", costText(summary.cost, bound), num(summary.sessions) + " sessions" + (summary.unpriced ? " · " + num(summary.unpriced) + " not fully priced" : "")], ["avg / session", costText(summary.average, bound), "median " + money(summary.median)], ["human messages", num(summary.humanMsgs), money(summary.costPerHumanMsg) + "/msg · " + (summary.callsPerHumanMsg || 0).toFixed(1) + " calls/msg"], ["agents", num(summary.agents), num(summary.calls) + " API calls · " + duration(Math.round(summary.agentMs / 1000)) + " agent time"], ["input", tokenCount(token.in), money(tokenCost.in)], ["output", tokenCount(token.out), money(tokenCost.out)], ["cache read", tokenCount(token.cr), money(tokenCost.cr)], ["cache write", tokenCount(token.cw), money(tokenCost.cw)]]; return
{items.map(([label, value, sub], index) =>
{label}{value}{sub}
)}
; } function Filters({ filters, facets, onChange }) { @@ -57,10 +58,10 @@ function Filters({ filters, facets, onChange }) { } function Breakdown({ rows, filters, onChange }) { const total = rows.reduce((sum, row) => sum + row.value, 0) || 1; - return
Breakdown
{["cost", "out", "cr", "cw", "in", "sessions", "agents", "calls", "humanMsgs", "duration", "agentMs"].map(metric => )}
{rows.slice(0, 12).map(row =>
{row.key}×{row.count}{filters.metric === "cost" ? money(row.value) : filters.metric === "agentMs" ? duration(Math.round(row.value / 1000)) : (["calls", "sessions", "agents", "humanMsgs"].includes(filters.metric) ? num(row.value) : ["agentMs", "duration"].includes(filters.metric) ? duration(Math.round(row.value / (filters.metric === "duration" ? 1 : 1000))) : tokenCount(row.value))}
)}
; + return
Breakdown
{["cost", "out", "cr", "cw", "in", "sessions", "agents", "calls", "humanMsgs", "duration", "agentMs"].map(metric => )}
{rows.slice(0, 12).map(row =>
{row.key}×{row.count}{filters.metric === "cost" ? costText(row.value, row.costConfidence) : filters.metric === "agentMs" ? duration(Math.round(row.value / 1000)) : (["calls", "sessions", "agents", "humanMsgs"].includes(filters.metric) ? num(row.value) : ["agentMs", "duration"].includes(filters.metric) ? duration(Math.round(row.value / (filters.metric === "duration" ? 1 : 1000))) : tokenCount(row.value))}
)}
; } function SortHeader({ label, field, filters, onChange, numeric = false }) { const active = filters.sort === field; return onChange({ ...filters, sort: field, dir: active && filters.dir === "desc" ? "asc" : "desc" })}>{label}{active ? filters.dir === "desc" ? "▼" : "▲" : "⇅"}; } -function SessionRow({ row }) { const token = row.tokens || {}, tokenCost = row.tokenCost || {}, workspace = row.workspace || {}; const metric = key => {token[key] ? <>{tokenCount(token[key])}{money(tokenCost[key])} : "·"}; return {row.live ? LIVE : closed}
{row.model || "unknown model"}{row.id.slice(0, 8)}{row.subtitle || row.desc ? " · " + (row.subtitle || row.desc) : ""}
{workspace.label || row.project}
{workspace.branch ? "⎇ " + workspace.branch : ""}
{shortDate(row.start)}{duration(row.durationS)}{row.agentMs ? duration(Math.round(row.agentMs / 1000)) : "·"}{num(row.agents)}{num(row.apiCalls)}{num(row.humanMsgs)}{row.costPerHumanMsg != null && {money(row.costPerHumanMsg)}/msg}{["in", "out", "cr", "cw"].map(metric)}{money(row.cost)}; } +function SessionRow({ row }) { const token = row.tokens || {}, tokenCost = row.tokenCost || {}, workspace = row.workspace || {}; const metric = key => {token[key] ? <>{tokenCount(token[key])}{money(tokenCost[key])} : "·"}; return {row.live ? LIVE : closed}
{row.model || "unknown model"}{row.id.slice(0, 8)}{row.subtitle || row.desc ? " · " + (row.subtitle || row.desc) : ""}
{workspace.label || row.project}
{workspace.branch ? "⎇ " + workspace.branch : ""}
{shortDate(row.start)}{duration(row.durationS)}{row.agentMs ? duration(Math.round(row.agentMs / 1000)) : "·"}{num(row.agents)}{num(row.apiCalls)}{num(row.humanMsgs)}{row.costPerHumanMsg != null && {money(row.costPerHumanMsg)}/msg}{["in", "out", "cr", "cw"].map(metric)}{costText(row.cost, row.costConfidence)}; } function VirtualSessionTable({ filters, onChange, data }) { const queryClient = useQueryClient(); const [scrollMargin, setScrollMargin] = useState(0); const tableRef = React.useCallback(node => { if (node) setScrollMargin(node.offsetTop); }, []); const pageSize = 100; const virtual = useWindowVirtualizer({ count: data?.matchedCount || 0, estimateSize: () => 58, overscan: 12, scrollMargin }); diff --git a/src/parse.mjs b/src/parse.mjs index c06ea1d..b0705b7 100644 --- a/src/parse.mjs +++ b/src/parse.mjs @@ -4,7 +4,10 @@ // keeping the MAX-usage variant, which is how the harness resolves its own // streaming duplicates). Re-parsing any range converges to the same numbers. import fs from "node:fs"; -import { priceClaude, ZERO_PARTS } from "./prices.mjs"; +import { priceUsage, provenance, ZERO_PARTS } from "./pricing/index.mjs"; + +// Claude Code is billed at Anthropic's list prices. +const priceClaude = (model, ts, u) => priceUsage("anthropic", model, ts, u); // The harness stamps locally-generated messages ("No response requested.", // interrupts) with this pseudo-model and an all-zero usage block. It is not a @@ -360,7 +363,9 @@ export class SessionFileParser { const out = []; for (const [id, v] of this.seen) { const r = priceClaude(v.model, v.ts, v.u); - out.push({ id, ts: v.ts, cost: r.usd, model: v.model, pid: turnOf(v.uuid), + // An unpriced call has no cost, not a zero one. + out.push({ id, ts: v.ts, cost: r.confidence === "n/a" ? null : r.usd, price: r.price, + model: v.model, pid: turnOf(v.uuid), tokens: { in: v.u.in, out: v.u.out, cr: v.u.cr, cw: v.u.c5 + v.u.c1h }, costParts: r.parts, acts: v.acts || [] }); } @@ -389,7 +394,8 @@ export class SessionFileParser { const t = { input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 }; const byModel = new Map(); // ranks the model list: busiest first let usd = 0; const cp = ZERO_PARTS(); - let confidence = "verified"; const unknown = new Set(); + let confidence = "verified"; const unknown = new Set(), prices = new Set(); + let priced = 0, unpriced = 0; for (const [, v] of this.seen) { t.input += v.u.in; t.output += v.u.out; t.cacheRead += v.u.cr; t.cacheWrite5m += v.u.c5; t.cacheWrite1h += v.u.c1h; @@ -398,6 +404,8 @@ export class SessionFileParser { usd += r.usd; for (const k in cp) cp[k] += r.parts[k]; if (r.unknownModel) unknown.add(r.unknownModel); + if (r.price) prices.add(r.price); + if (r.confidence === "n/a") unpriced++; else priced++; if (r.confidence === "computed" && confidence === "verified") confidence = "computed"; } return { @@ -412,8 +420,10 @@ export class SessionFileParser { skills: [...this.skills], branch: [...this.branches][0] || null, cwd: this.cwd, version: this.version, repo: this.repo, tokens: t, costOwn: usd, costParts: cp, - costConfidence: unknown.size ? "partial" : confidence, - unknownModels: [...unknown], identity: this.identity, + // No call priced at all is "n/a" -- an unknown figure, not a $0 one; some + // unpriced calls (or a token class with no published rate) is "partial". + costConfidence: priced === 0 && unpriced > 0 ? "n/a" : unknown.size ? "partial" : confidence, + unknownModels: [...unknown], pricing: [...prices].map(provenance), identity: this.identity, turns, turnByToolUse, calls: this.#calls(turnOf), codex: [...this.codexSessions.values()], }; diff --git a/src/prices.mjs b/src/prices.mjs deleted file mode 100644 index d35e286..0000000 --- a/src/prices.mjs +++ /dev/null @@ -1,79 +0,0 @@ -// Price tables per provider, $/MTok: [input, output, cacheRead, cacheWrite5m, cacheWrite1h]. -// "confidence" of a computed cost: -// verified – multipliers validated to 8 decimals against the harness's own -// total_cost_usd (haiku-4-5 and opus-5, 2026-09-01) -// computed – list price × tokens, multipliers assumed equal to verified models -// n/a – provider reports no usage -const CLAUDE = { - "claude-opus-5": { p: [5, 25, 0.50, 6.25, 10], confidence: "verified" }, - "claude-fable-5": { p: [10, 50, 1.00, 12.50, 20], confidence: "computed" }, - "claude-sonnet-5": { p: [3, 15, 0.30, 3.75, 6], confidence: "computed" }, - "claude-haiku-4-5-20251001":{ p: [1, 5, 0.10, 1.25, 2], confidence: "verified" }, - "claude-haiku-4-5": { p: [1, 5, 0.10, 1.25, 2], confidence: "verified" }, -}; -// Sonnet 5 introductory pricing window (through 2026-08-31). -const SONNET_INTRO = [2, 10, 0.20, 2.50, 4]; - -// usage: {in, out, cr, c5, c1h} ts: ISO timestamp of the message -// "parts" splits the same cost per token class (cache write folds 5m and 1h -// together, as every view does); their sum equals usd up to float rounding. -export const ZERO_PARTS = () => ({ in: 0, out: 0, cr: 0, cw: 0 }); -export function priceClaude(model, ts, u) { - const row = CLAUDE[model]; - if (!row) return { usd: 0, parts: ZERO_PARTS(), confidence: "n/a", unknownModel: model }; - let p = row.p; - if (model === "claude-sonnet-5" && ts && ts < "2026-09-01") p = SONNET_INTRO; - const usd = (u.in * p[0] + u.out * p[1] + u.cr * p[2] + u.c5 * p[3] + u.c1h * p[4]) / 1e6; - const parts = { in: u.in * p[0] / 1e6, out: u.out * p[1] / 1e6, cr: u.cr * p[2] / 1e6, - cw: (u.c5 * p[3] + u.c1h * p[4]) / 1e6 }; - return { usd, parts, confidence: row.confidence }; -} - -// --------------------------------------------------------------------------- -// Codex (OpenAI) list prices, $/MTok, same 5-slot shape and same units. -// -// Retrieved 2026-09-06 from: -// - https://developers.openai.com/api/docs/pricing (gpt-6-astra, gpt-5.5) -// - the gpt-5.6-sol model page on the same site (gpt-5.6-sol) -// - corroborated against OpenRouter's `openai/gpt-6-astra` listing -// -// All three rows are "computed", never "verified": Codex exposes no -// total_cost_usd anywhere in its store to validate a multiplier against, which -// is exactly what "verified" means above. -// -// gpt-5.6-sol's rate is PROMOTIONAL through ~2026-11-21 and no post-window rate -// is published. That is why there is no SONNET_INTRO-style window branch here: -// the machinery above works because both ends of the window and both rates are -// known; here the far side would be a guess, and a guessed price is the -// fabrication this file exists to avoid. Revisit when OpenAI publishes one. -// -// Cache-write slots are 1.25x uncached input, the better-corroborated of the -// published readings, and both tiers are priced identically because Codex -// reports one undifferentiated cache_write_input_tokens with no 5m/1h -// distinction -- so the tier the provider maps it into cannot change a figure. -// Currently unexercised either way: cache_write_input_tokens is 0 on every -// token_usage_record on this host. -// -// Deliberately not modelled: OpenAI's long-context (>=272K input) multiplier, -// the `fast` service tier, and Batch/Flex discounts. A flat row does not carry -// them, no sampled request exceeds 272K input tokens, and per-effort rows are -// already deferred by the spec. -const CODEX = { - "gpt-6-astra": { p: [10, 50, 1.00, 12.50, 12.50], confidence: "computed" }, - "gpt-5.6-sol": { p: [ 4, 20, 0.40, 5.00, 5.00], confidence: "computed" }, - "gpt-5.5": { p: [ 5, 30, 0.50, 6.25, 6.25], confidence: "computed" }, -}; - -// Sibling of priceClaude, same signature, same return shape, same unknown-model -// fallback. `ts` is accepted and unused: it is precisely the parameter a -// promotional-window check would need (priceClaude uses it for exactly that), -// and keeping the signatures identical is what lets one caller shape serve both. -export function priceCodex(model, ts, u) { - const row = CODEX[model]; - if (!row) return { usd: 0, parts: ZERO_PARTS(), confidence: "n/a", unknownModel: model }; - const p = row.p; - const usd = (u.in * p[0] + u.out * p[1] + u.cr * p[2] + u.c5 * p[3] + u.c1h * p[4]) / 1e6; - const parts = { in: u.in * p[0] / 1e6, out: u.out * p[1] / 1e6, cr: u.cr * p[2] / 1e6, - cw: (u.c5 * p[3] + u.c1h * p[4]) / 1e6 }; - return { usd, parts, confidence: row.confidence }; -} diff --git a/src/pricing/index.mjs b/src/pricing/index.mjs new file mode 100644 index 0000000..075e9a7 --- /dev/null +++ b/src/pricing/index.mjs @@ -0,0 +1,221 @@ +// Pricing: what one API call cost, and where that number came from. +// +// There is no price table in the source. Prices live in a ledger of dated +// entries, one per (vendor, model, rate set), assembled from two files: +// +// snapshot.json bundled with the release: the last-known official prices, +// written by `npm run prices:snapshot` from the same sources +// and parsers the runtime uses. It is what an offline host, +// or a host whose first refresh has not finished, prices with. +// the local cache prices observed live at runtime (see refreshPrices()), +// under $XDG_CACHE_HOME/agent-atlas/prices.json by default. +// +// The ledger is append-only, and that is what keeps a historical answer fixed. +// A refresh that sees new rates for a model appends an entry effective FROM THE +// MOMENT IT WAS OBSERVED; nothing before that moment can resolve to it. A model's +// first entry has `from: null` -- the earliest price known for it applies back to +// its first use, since no page states when a price started. A failed or partial +// refresh appends nothing, and a model missing from a later page is not removed. +// +// A call whose model has no entry is unpriced: usd 0 with confidence "n/a" and +// the model named in unknownModel. Callers carry that as "unknown", never as a +// real $0 -- see node.mjs and the renderers. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { SOURCES, candidates } from "./sources.mjs"; + +export const TTL_MS = 24 * 3600e3; // a source is re-read once a day +export const RETRY_MS = 3600e3; // a failed source is retried after an hour +const SCHEMA = 1; +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SNAPSHOT = path.join(HERE, "snapshot.json"); + +export const ZERO_PARTS = () => ({ in: 0, out: 0, cr: 0, cw: 0 }); + +/** AGENT_ATLAS_PRICES_CACHE overrides the file; the directory is XDG's cache dir. */ +export const cachePath = () => process.env.AGENT_ATLAS_PRICES_CACHE || + path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "agent-atlas", "prices.json"); +/** AGENT_ATLAS_PRICING=offline: never touch the network; bundled snapshot + cache only. */ +export const offline = () => process.env.AGENT_ATLAS_PRICING === "offline"; + +const emptyStore = () => ({ schema: SCHEMA, sources: {}, entries: [] }); +function readStore(file) { + try { + const s = JSON.parse(fs.readFileSync(file, "utf8")); + if (s && s.schema === SCHEMA && Array.isArray(s.entries)) return { sources: {}, ...s }; + } catch { /* absent or unreadable: an empty store, never an error */ } + return emptyStore(); +} + +const entryId = e => `${e.vendor}/${e.model}@${e.observedAt}`; +const sameRates = (a, b) => JSON.stringify([a.rates, a.long ?? null]) === JSON.stringify([b.rates, b.long ?? null]); + +// In-memory state: the merged ledger, keyed "vendor/model" -> entries sorted by +// `from` (null first). `revision` moves whenever the ledger changes, so callers +// holding priced results can tell theirs are stale. +let state = null; +let revision = 0; + +function build(snapshot, cache) { + const byKey = new Map(), byId = new Map(); + const add = (e, kind) => { + const k = e.vendor + "/" + e.model, list = byKey.get(k) || []; + // Two entries with the same `from` (a model first observed live, later also + // shipped in a snapshot): the earlier observation wins, because that is the + // answer the user has already been shown. + const clash = list.findIndex(x => x.from === e.from); + if (clash >= 0) { if (list[clash].observedAt <= e.observedAt) return; list.splice(clash, 1); } + const x = { ...e, kind, id: entryId(e), fromMs: e.from == null ? -Infinity : Date.parse(e.from) }; + list.push(x); byKey.set(k, list); + }; + for (const e of snapshot.entries) add(e, "bundled"); + for (const e of cache.entries) add(e, "live"); + for (const list of byKey.values()) { + list.sort((a, b) => a.fromMs - b.fromMs); + for (const x of list) byId.set(x.id, x); + } + return { snapshot, cache, byKey, byId }; +} + +function load() { + if (!state) state = build(readStore(SNAPSHOT), readStore(cachePath())); + return state; +} + +/** Drop the in-memory ledger; the next lookup re-reads snapshot and cache. */ +export function reload() { state = null; revision++; } +export const pricingRevision = () => revision; + +/** The entry in force for `vendor/model` at ISO time `ts`, or null. */ +export function resolve(vendor, model, ts) { + if (model == null) return null; + const { byKey } = load(); + for (const m of candidates(vendor, model)) { + const list = byKey.get(vendor + "/" + m); + if (!list) continue; + const t = ts ? Date.parse(ts) : NaN; + // No timestamp: the earliest entry, the one answer that can never move. + if (!Number.isFinite(t)) return list[0]; + let hit = list[0]; + for (const x of list) if (x.fromMs <= t) hit = x; + return hit; + } + return null; +} + +/** + * Price one call. `u` is {in, out, cr, c5, c1h} tokens. + * Returns {usd, parts, confidence, price} where `price` is the provenance id of + * the entry used (see provenance()), or, when no entry exists, usd 0 with + * confidence "n/a" and `unknownModel` set. A token class with tokens but no + * published rate leaves that part unpriced and the call "partial". + */ +export function priceUsage(vendor, model, ts, u) { + const e = resolve(vendor, model, ts); + if (!e) return { usd: 0, parts: ZERO_PARTS(), confidence: "n/a", unknownModel: model, price: null }; + const r = e.long && u.in + u.cr + u.c5 + u.c1h > e.long.above ? e.long.rates : e.rates; + let missing = false; + const part = k => { + if (!u[k]) return 0; + if (r[k] == null) { missing = true; return 0; } + return u[k] * r[k] / 1e6; + }; + const parts = { in: part("in"), out: part("out"), cr: part("cr"), cw: part("c5") + part("c1h") }; + const usd = parts.in + parts.out + parts.cr + parts.cw; + const confidence = missing ? "partial" : e.verified ? "verified" : "computed"; + return { usd, parts, confidence, price: e.id, ...(missing ? { unknownModel: `${model} (unpublished rate)` } : {}) }; +} + +/** Machine-readable provenance of a price id returned by priceUsage(). */ +export function provenance(id) { + const e = load().byId.get(id); + if (!e) return null; + const src = (e.kind === "bundled" ? state.snapshot : state.cache).sources[e.vendor] || {}; + return { id: e.id, vendor: e.vendor, model: e.model, currency: "USD", unit: "per 1M tokens", + rates: e.rates, long: e.long ?? null, from: e.from, observedAt: e.observedAt, + source: { kind: e.kind, url: e.url || src.page || null }, + ...(e.verified ? { verified: e.verified } : {}) }; +} + +/** + * Merge freshly parsed rows into `store` (mutated). Appends only on change, and + * only relative to everything already known (bundled + cached), so re-reading an + * unchanged page writes nothing. Returns the number of entries appended. + */ +export function observe(store, known, source, rows, observedAt) { + let added = 0; + for (const r of rows) { + const list = known.get(source.vendor + "/" + r.model) || []; + const latest = list[list.length - 1]; + if (latest && sameRates(latest, r)) continue; + const e = { vendor: source.vendor, model: r.model, from: latest ? observedAt : null, observedAt, + rates: r.rates, long: r.long ?? null, url: source.page }; + store.entries.push(e); + known.set(source.vendor + "/" + r.model, [...list, { ...e, fromMs: Date.parse(observedAt) }]); + added++; + } + return added; +} + +function writeStore(file, store) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + // One entry per line: a refreshed snapshot diffs as exactly the lines appended. + const { entries, ...head } = store; + fs.writeFileSync(tmp, JSON.stringify(head, null, 1).replace(/\n}$/, ',\n "entries": [\n') + + entries.map(e => " " + JSON.stringify(e)).join(",\n") + "\n ]\n}\n"); + fs.renameSync(tmp, file); +} + +let inflight = null; +/** + * Re-read every source whose last read is older than TTL_MS (or all, with + * force). Never throws and never blocks a caller that does not await it: a + * failure is recorded per source and changes no price. Resolves to + * {changed, sources: [{vendor, status, added?, error?}]}. + */ +export function refreshPrices({ force = false, timeoutMs = 10_000, fetchImpl = globalThis.fetch, now = () => new Date() } = {}) { + if (offline()) return Promise.resolve({ changed: false, sources: [], offline: true }); + if (inflight) return inflight; + inflight = (async () => { + const file = cachePath(); + const results = []; + let changed = false; + for (const source of SOURCES) { + // Re-read the file per source: another process may have written it. + const store = readStore(file); + const prev = store.sources[source.vendor] || {}; + const t = now(); + const last = Date.parse(prev.retrievedAt || 0) || 0, tried = Date.parse(prev.attemptedAt || 0) || 0; + if (!force && (t - last < TTL_MS || t - tried < RETRY_MS)) { results.push({ vendor: source.vendor, status: "fresh" }); continue; } + const observedAt = t.toISOString(); + try { + const res = await fetchImpl(source.url, { signal: AbortSignal.timeout(timeoutMs), + headers: { accept: "text/markdown, text/plain" } }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const rows = source.parse(await res.text()); + const known = build(readStore(SNAPSHOT), store).byKey; + const added = observe(store, known, source, rows, observedAt); + store.sources[source.vendor] = { page: source.page, url: source.url, retrievedAt: observedAt, + attemptedAt: observedAt, models: rows.length, error: null }; + writeStore(file, store); + if (added) changed = true; + results.push({ vendor: source.vendor, status: "ok", added }); + } catch (e) { + const error = e && e.name === "TimeoutError" ? `timed out after ${timeoutMs} ms` : String(e && e.message || e); + store.sources[source.vendor] = { ...prev, attemptedAt: observedAt, error }; + try { writeStore(file, store); } catch { /* an unwritable cache is not fatal either */ } + results.push({ vendor: source.vendor, status: "failed", error }); + } + } + if (changed) reload(); else state = null; // re-read status either way; revision moves only on change + return { changed, sources: results }; + })().finally(() => { inflight = null; }); + return inflight; +} + +/** For the snapshot script: the bundled file's path and its reader/writer. */ +export const snapshotFile = SNAPSHOT; +export { readStore, writeStore, build }; diff --git a/src/pricing/snapshot.json b/src/pricing/snapshot.json new file mode 100644 index 0000000..491c6dd --- /dev/null +++ b/src/pricing/snapshot.json @@ -0,0 +1,78 @@ +{ + "sources": { + "anthropic": { + "page": "https://platform.claude.com/docs/en/about-claude/pricing", + "url": "https://platform.claude.com/docs/en/about-claude/pricing.md", + "retrievedAt": "2026-09-29T19:15:12.388Z", + "models": 19 + }, + "openai": { + "page": "https://developers.openai.com/api/docs/pricing", + "url": "https://developers.openai.com/api/docs/pricing.md", + "retrievedAt": "2026-09-29T19:15:12.388Z", + "models": 40 + } + }, + "schema": 1, + "entries": [ + {"vendor":"anthropic","model":"claude-fable-5-1","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":10,"out":50,"cr":0.25,"c5":12.5,"c1h":20},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-mythos-5-1","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":10,"out":50,"cr":0.25,"c5":12.5,"c1h":20},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-fable-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":10,"out":50,"cr":1,"c5":12.5,"c1h":20},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-mythos-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":10,"out":50,"cr":1,"c5":12.5,"c1h":20},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-opus-5-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":4,"out":20,"cr":0.2,"c5":5,"c1h":8},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-opus-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":5,"out":25,"cr":0.5,"c5":6.25,"c1h":10},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing","verified":"2026-09-01: cost reproduced the harness total_cost_usd to 8 decimals"}, + {"vendor":"anthropic","model":"claude-opus-4-8","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":5,"out":25,"cr":0.5,"c5":6.25,"c1h":10},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-opus-4-7","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":5,"out":25,"cr":0.5,"c5":6.25,"c1h":10},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-opus-4-6","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":5,"out":25,"cr":0.5,"c5":6.25,"c1h":10},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-opus-4-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":5,"out":25,"cr":0.5,"c5":6.25,"c1h":10},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-opus-4-1","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":15,"out":75,"cr":1.5,"c5":18.75,"c1h":30},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-opus-4","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":15,"out":75,"cr":1.5,"c5":18.75,"c1h":30},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-sonnet-5-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2,"out":10,"cr":0.2,"c5":2.5,"c1h":4},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-sonnet-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2,"out":10,"cr":0.2,"c5":2.5,"c1h":4},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-sonnet-4-6","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":3,"out":15,"cr":0.3,"c5":3.75,"c1h":6},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-sonnet-4-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":3,"out":15,"cr":0.3,"c5":3.75,"c1h":6},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-sonnet-4","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":3,"out":15,"cr":0.3,"c5":3.75,"c1h":6},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"anthropic","model":"claude-haiku-4-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":1,"out":5,"cr":0.1,"c5":1.25,"c1h":2},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing","verified":"2026-09-01: cost reproduced the harness total_cost_usd to 8 decimals"}, + {"vendor":"anthropic","model":"claude-haiku-3-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.8,"out":4,"cr":0.08,"c5":1,"c1h":1.6},"long":null,"url":"https://platform.claude.com/docs/en/about-claude/pricing"}, + {"vendor":"openai","model":"gpt-6-astra","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":10,"out":50,"cr":1,"c5":12.5,"c1h":12.5},"long":{"above":272000,"rates":{"in":20,"out":75,"cr":2,"c5":25,"c1h":25}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-6.1-sol","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2,"out":10,"cr":0.1,"c5":2.5,"c1h":2.5},"long":{"above":272000,"rates":{"in":4,"out":15,"cr":0.2,"c5":5,"c1h":5}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-6-luna","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.1,"out":0.5,"cr":0.01,"c5":0.125,"c1h":0.125},"long":{"above":272000,"rates":{"in":0.2,"out":0.75,"cr":0.02,"c5":0.25,"c1h":0.25}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-6-sol","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2,"out":10,"cr":0.2,"c5":2.5,"c1h":2.5},"long":{"above":272000,"rates":{"in":4,"out":15,"cr":0.4,"c5":5,"c1h":5}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.6-sol","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":4,"out":20,"cr":0.4,"c5":5,"c1h":5},"long":{"above":272000,"rates":{"in":8,"out":30,"cr":0.8,"c5":10,"c1h":10}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.6-terra","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2,"out":12,"cr":0.2,"c5":2.5,"c1h":2.5},"long":{"above":272000,"rates":{"in":4,"out":18,"cr":0.4,"c5":5,"c1h":5}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.6-luna","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.2,"out":1.2,"cr":0.02,"c5":0.25,"c1h":0.25},"long":{"above":272000,"rates":{"in":0.4,"out":1.8,"cr":0.04,"c5":0.5,"c1h":0.5}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":5,"out":30,"cr":0.5,"c5":null,"c1h":null},"long":{"above":272000,"rates":{"in":10,"out":45,"cr":1,"c5":null,"c1h":null}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.5-pro","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":30,"out":180,"cr":null,"c5":null,"c1h":null},"long":{"above":272000,"rates":{"in":60,"out":270,"cr":null,"c5":null,"c1h":null}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.4","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2.5,"out":15,"cr":0.25,"c5":null,"c1h":null},"long":{"above":272000,"rates":{"in":5,"out":22.5,"cr":0.5,"c5":null,"c1h":null}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.4-mini","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.75,"out":4.5,"cr":0.075,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.4-nano","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.2,"out":1.25,"cr":0.02,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.4-pro","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":30,"out":180,"cr":null,"c5":null,"c1h":null},"long":{"above":272000,"rates":{"in":60,"out":270,"cr":null,"c5":null,"c1h":null}},"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.2","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":1.75,"out":14,"cr":0.175,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.2-pro","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":21,"out":168,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5.1","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":1.25,"out":10,"cr":0.125,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":1.25,"out":10,"cr":0.125,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5-mini","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.25,"out":2,"cr":0.025,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5-nano","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.05,"out":0.4,"cr":0.005,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-5-pro","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":15,"out":120,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-4.1","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2,"out":8,"cr":0.5,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-4.1-mini","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.4,"out":1.6,"cr":0.1,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-4.1-nano","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.1,"out":0.4,"cr":0.025,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-4o","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2.5,"out":10,"cr":1.25,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-4o-2024-05-13","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":5,"out":15,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-4o-mini","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.15,"out":0.6,"cr":0.075,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"o1","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":15,"out":60,"cr":7.5,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"o1-pro","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":150,"out":600,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"o3-pro","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":20,"out":80,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"o3","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2,"out":8,"cr":0.5,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"o4-mini","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":1.1,"out":4.4,"cr":0.275,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"o3-mini","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":1.1,"out":4.4,"cr":0.55,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-4-turbo-2024-04-09","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":10,"out":30,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-4-0613","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":30,"out":60,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-3.5-turbo","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.5,"out":1.5,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-3.5-turbo-0125","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.5,"out":1.5,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-3.5-turbo-1106","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":1,"out":2,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"gpt-3.5-turbo-instruct","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":1.5,"out":2,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"davinci-002","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":2,"out":2,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"}, + {"vendor":"openai","model":"babbage-002","from":null,"observedAt":"2026-09-29T19:15:12.388Z","rates":{"in":0.4,"out":0.4,"cr":null,"c5":null,"c1h":null},"long":null,"url":"https://developers.openai.com/api/docs/pricing"} + ] +} diff --git a/src/pricing/sources.mjs b/src/pricing/sources.mjs new file mode 100644 index 0000000..fbe1303 --- /dev/null +++ b/src/pricing/sources.mjs @@ -0,0 +1,114 @@ +// Price sources: where a list price comes from and how its page is read. +// +// Both vendors publish their price tables as Markdown at a stable URL, next to +// the HTML page a human reads. That is the source: the official page itself, +// not a copy of it in a third-party dataset that has to be kept in sync by +// somebody else. The cost is a parser per page; the parser is strict on purpose +// (see parse*), so a redesigned page is a failed refresh, never a wrong price. +// +// A source knows nothing about harnesses: it names a vendor ("anthropic", +// "openai"). Which harness is billed by which vendor is the provider's business. +// +// Rates are USD per million tokens in five token classes, the classes every +// provider already reports usage in: +// in uncached input cr cache read (hit / refresh) +// out output c5 cache write, 5-minute tier +// c1h cache write, 1-hour tier +// A class the page does not publish is null, never a guess. `long` holds a +// second rate set that applies above `above` input tokens per request. + +const MIN_ROWS = 5; // fewer than this parsed = the page changed shape + +/** "$12.50 / MTok1" -> 12.5; "-" or "" -> null; anything else throws. */ +export function dollars(cell) { + const s = String(cell).replace(/.*?<\/sup>/gi, "").replace(/<[^>]*>/g, "").trim(); + if (s === "-" || s === "" || s === "—") return null; + const m = s.match(/^\$\s*([0-9]+(?:\.[0-9]+)?)(?:\s*\/\s*MTok)?$/i); + if (!m) throw new Error(`unreadable price cell '${s}'`); + return Number(m[1]); +} + +/** The first Markdown table after `anchor`: its header cells and body rows. */ +function tableAfter(text, anchor) { + const at = text.indexOf(anchor); + if (at < 0) throw new Error(`section '${anchor}' not found`); + const lines = text.slice(at).split("\n"); + let i = lines.findIndex(l => l.trim().startsWith("|")); + if (i < 0) throw new Error(`no table after '${anchor}'`); + const cells = l => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim()); + const head = cells(lines[i]).map(h => h.toLowerCase()); + const rows = []; + for (i += 2; i < lines.length && lines[i].trim().startsWith("|"); i++) rows.push(cells(lines[i])); + return { head, rows }; +} + +const col = (head, ...names) => { + for (const n of names) { const i = head.indexOf(n); if (i >= 0) return i; } + throw new Error(`column '${names[0]}' not found`); +}; + +// "Claude Opus 5.5" -> "claude-opus-5-5"; "Claude Mythos 5.1 ([limited…](…))" -> "claude-mythos-5-1". +// Only the current naming scheme maps cleanly, and only it is needed: the retired +// "Claude Haiku 3.5" becomes "claude-haiku-3-5", an id no transcript carries, so +// it is simply never looked up. +const anthropicId = name => "claude-" + name.replace(/\(.*$/, "").trim() + .replace(/^claude\s+/i, "").toLowerCase().replace(/[.\s]+/g, "-"); + +export function parseAnthropic(text) { + const { head, rows } = tableAfter(text, "## Model pricing"); + const c = { name: col(head, "model"), in: col(head, "base input tokens"), + c5: col(head, "5m cache writes"), c1h: col(head, "1h cache writes"), + cr: col(head, "cache hits and refreshes"), out: col(head, "output tokens") }; + const out = rows.map(r => ({ model: anthropicId(r[c.name]), rates: { + in: dollars(r[c.in]), out: dollars(r[c.out]), cr: dollars(r[c.cr]), + c5: dollars(r[c.c5]), c1h: dollars(r[c.c1h]) }, long: null })); + return checked(out, "anthropic"); +} + +// OpenAI publishes one standard table with a short- and a long-context half. +// Only the Standard tier is read: Batch, Flex and Fast are request options the +// Codex store does not record per call, so there is nothing to select them by. +export const OPENAI_LONG_ABOVE = 272_000; +export function parseOpenAI(text) { + const { head, rows } = tableAfter(text, "### Standard pricing data"); + const c = { name: col(head, "model"), + in: col(head, "short context input"), cr: col(head, "short context cached input"), + cw: col(head, "short context cache writes"), out: col(head, "short context output"), + lin: col(head, "long context input"), lcr: col(head, "long context cached input"), + lcw: col(head, "long context cache writes"), lout: col(head, "long context output") }; + const out = rows.map(r => { + const rates = { in: dollars(r[c.in]), out: dollars(r[c.out]), cr: dollars(r[c.cr]), + c5: dollars(r[c.cw]), c1h: dollars(r[c.cw]) }; + const lin = dollars(r[c.lin]); + return { model: r[c.name].replace(/\s*\(.*\)\s*$/, "").trim(), rates, + long: lin == null ? null : { above: OPENAI_LONG_ABOVE, rates: { in: lin, out: dollars(r[c.lout]), + cr: dollars(r[c.lcr]), c5: dollars(r[c.lcw]), c1h: dollars(r[c.lcw]) } } }; + }); + return checked(out, "openai"); +} + +/** A parse that looks wrong is an error: a refresh must fail rather than store it. */ +function checked(rows, vendor) { + const ok = rows.filter(r => r.model && r.rates.in > 0 && r.rates.out > 0); + if (ok.length < MIN_ROWS) throw new Error(`${vendor}: only ${ok.length} priced rows parsed`); + return ok; +} + +// Transcript model ids that differ from the listed id only by decoration. +const normalizers = { + // "claude-haiku-4-5-20251001" (dated snapshot) and "claude-sonnet-5[1m]" + // (the harness's 1M-context marker; 1M context is billed at standard rates). + anthropic: m => [m, m.replace(/\[[^\]]*\]$/, "").replace(/-\d{8}$/, "")], + openai: m => [m], +}; + +export const SOURCES = [ + { vendor: "anthropic", url: "https://platform.claude.com/docs/en/about-claude/pricing.md", + page: "https://platform.claude.com/docs/en/about-claude/pricing", parse: parseAnthropic }, + { vendor: "openai", url: "https://developers.openai.com/api/docs/pricing.md", + page: "https://developers.openai.com/api/docs/pricing", parse: parseOpenAI }, +]; + +/** Candidate ids to look a transcript's model up by, most specific first. */ +export const candidates = (vendor, model) => + [...new Set((normalizers[vendor] || (m => [m]))(String(model)))]; diff --git a/src/providers/codex.mjs b/src/providers/codex.mjs index d831079..4af93ff 100644 --- a/src/providers/codex.mjs +++ b/src/providers/codex.mjs @@ -21,7 +21,10 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -import { ZERO_PARTS, priceCodex } from "../prices.mjs"; +import { ZERO_PARTS, priceUsage, provenance, pricingRevision } from "../pricing/index.mjs"; + +// Codex is billed at OpenAI's list prices. +const priceCodex = (model, ts, u) => priceUsage("openai", model, ts, u); import { finish } from "./node.mjs"; export const name = "codex"; @@ -212,7 +215,7 @@ function readRollout(file) { // Pass 2: price every record with its own turn's model. const tokens = ZERO_TOKENS(); const raw = { input_tokens: 0, cached_input_tokens: 0, cache_write_input_tokens: 0, output_tokens: 0 }; - const ids = new Set(), unknown = new Set(), byModel = new Map(); + const ids = new Set(), unknown = new Set(), byModel = new Map(), prices = new Set(); const costParts = ZERO_PARTS(); const turns = new Map(); // turn_id -> {usd(raw), apiCalls, tokens, costParts} const calls = []; @@ -249,6 +252,7 @@ function readRollout(file) { } else { pr = priceCodex(model, r.ts, uu); if (pr.unknownModel != null) unknown.add(pr.unknownModel); + if (pr.price) prices.add(pr.price); byModel.set(model, (byModel.get(model) || 0) + (u.total_tokens || 0)); } if (pr.confidence === "n/a") unpriced++; else priced++; @@ -262,7 +266,8 @@ function readRollout(file) { g.tokens.output += uu.out; g.tokens.cacheWrite5m += uu.c5; for (const k in g.costParts) g.costParts[k] += pr.parts[k]; - calls.push({ id: r.id ?? null, ts: r.ts ?? null, cost: pr.usd, model, + calls.push({ id: r.id ?? null, ts: r.ts ?? null, cost: pr.confidence === "n/a" ? null : pr.usd, + price: pr.price ?? null, model, tokens: { in: uu.in, out: uu.out, cr: uu.cr, cw: uu.c5 }, costParts: pr.parts, turnId: r.turnId ?? null }); } @@ -298,8 +303,8 @@ function readRollout(file) { tokens, apiCalls: ids.size, models: models.sort((a, b) => (byModel.get(b) || 0) - (byModel.get(a) || 0)), efforts, costOwn, costParts, unknownModels: [...unknown], - costConfidence: priced === 0 ? "n/a" : unpriced ? "partial" : "computed", - turnCost, calls, + costConfidence: priced === 0 ? "n/a" : unpriced || unknown.size ? "partial" : "computed", + pricing: [...prices].map(provenance), turnCost, calls, }; } @@ -315,10 +320,11 @@ const cacheKey = id => `codex:st:${id}`; function rollFor(cache, id, updatedMs, storedPath) { const key = cacheKey(id); const hit = cache && cache.get(key); - if (hit && hit.updatedMs === updatedMs) return hit.roll; + const rev = pricingRevision(); + if (hit && hit.updatedMs === updatedMs && hit.rev === rev) return hit.roll; const file = resolveRollout(storedPath); const roll = file ? readRollout(file) : null; - if (cache) cache.set(key, { updatedMs, roll }); + if (cache) cache.set(key, { updatedMs, rev, roll }); return roll; } @@ -490,7 +496,7 @@ function synth(t, roll, createdMs, updatedMs, hist) { const calls = (roll ? roll.calls : []).map(k => { const opens = k.turnId != null && hasUser && humanTurns.has(k.turnId) && !openers.has(k.turnId); if (opens) openers.add(k.turnId); - return { id: k.id, ts: k.ts, cost: k.cost, model: k.model, + return { id: k.id, ts: k.ts, cost: k.cost, price: k.price, model: k.model, tokens: { ...k.tokens }, costParts: { ...k.costParts }, // History only proves activity at turn granularity. Retain that // evidence on calls without pretending it identifies one response. @@ -525,6 +531,7 @@ function synth(t, roll, createdMs, updatedMs, hist) { costParts: roll ? { ...roll.costParts } : ZERO_PARTS(), costConfidence: roll ? roll.costConfidence : "n/a", unknownModels: roll ? [...roll.unknownModels] : [], + pricing: roll ? roll.pricing : [], // threads.title runs to 700+ characters on this host; first_user_message is // the real opening prompt. Codex has no summary, and threads.preview is only // a truncation of the same prompt, so claiming one would be a lie -- describe() diff --git a/src/providers/index.mjs b/src/providers/index.mjs index 27c3018..58c6162 100644 --- a/src/providers/index.mjs +++ b/src/providers/index.mjs @@ -4,6 +4,7 @@ // carries its own `provider`, which is the only dispatch key buildTree needs. import * as claude from "./claude.mjs"; import * as codex from "./codex.mjs"; +import { rollConfidence } from "./node.mjs"; // Order is not part of the contract -- both consumers sort by mtimeMs // immediately -- but it is fixed anyway so refFromPath is deterministic and so @@ -53,8 +54,7 @@ function hydrateCodex(node, cache) { const children = node.children.reduce((sum, child) => sum + child.cost.total, 0); node.cost.children = +children.toFixed(4); node.cost.total = +(own + children).toFixed(4); - if (node.children.some(child => child.cost.confidence === "partial" || child.cost.confidence === "n/a")) - node.cost.confidence = node.cost.confidence === "n/a" ? "n/a" : "partial"; + node.cost.confidence = rollConfidence(node.cost.ownConfidence, node.children); } /** The first provider that claims a path argument, or null if none does. */ diff --git a/src/providers/node.mjs b/src/providers/node.mjs index 47a8004..20ce1b4 100644 --- a/src/providers/node.mjs +++ b/src/providers/node.mjs @@ -12,7 +12,20 @@ // claude.mjs's module-level `name` and is now always `extra.provider` -- the // key the codex leaf and the codex provider already set, so neither of those // call sites had to move. -import { ZERO_PARTS } from "../prices.mjs"; +import { ZERO_PARTS } from "../pricing/index.mjs"; + +/** + * The confidence of a subtree total from its own part's and its children's. + * "n/a" only when nothing in it is priced -- a priced child under an unpriced + * parent is a known lower bound, "partial", and must not be hidden as unknown. + * Any unpriced or partial part makes the whole partial. + */ +export function rollConfidence(own, children) { + const all = [own, ...children.map(c => c.cost.confidence)]; + if (all.every(c => c === "n/a")) return "n/a"; + if (all.some(c => c === "n/a" || c === "partial")) return "partial"; + return own === "verified" && all.includes("computed") ? "computed" : own; +} /** * One tree node. @@ -57,11 +70,7 @@ export function finish(agent, description, agentId, via, st, children, extra, tu } const childUsd = children.reduce((a, c) => a + c.cost.total, 0); const own = st ? st.costOwn : 0; - let conf = st ? st.costConfidence : "n/a"; - // A displayed subtree is incomplete if any descendant is incomplete, even - // when this node's own token records were fully priced. - if (children.some(c => c.cost.confidence === "partial" || c.cost.confidence === "n/a")) - conf = conf === "n/a" ? "n/a" : "partial"; + const conf = rollConfidence(st ? st.costConfidence : "n/a", children); // Human effort belongs to the session a person typed into: a subagent's // "user message" is its parent's errand, so only a root node keeps the // parser's count. This is the single place root-ness is enforced, and the @@ -95,8 +104,12 @@ export function finish(agent, description, agentId, via, st, children, extra, tu ...("round" in extra && extra.round !== undefined ? { round: extra.round } : {}), ...(extra.missingTranscript ? { missingTranscript: true } : {}), ...(extra.reported ? { reported: extra.reported } : {}), + // confidence covers the subtree (what `total` shows); ownConfidence covers + // `own` alone. "n/a" means unknown, never zero: renderers print it as such. + // provenance lists every price entry this node's own calls were priced with. cost: { own: +own.toFixed(4), children: +childUsd.toFixed(4), - total, confidence: conf }, + total, confidence: conf, ownConfidence: st ? st.costConfidence : "n/a", + provenance: (st && st.pricing) || [] }, turns, calls: callList, children, diff --git a/src/render.mjs b/src/render.mjs index e7fa63c..a6d842b 100644 --- a/src/render.mjs +++ b/src/render.mjs @@ -7,6 +7,9 @@ const usd = n => n > 0 && n < 0.01 ? "$" + n.toFixed(4) : "$" + n.toFixed(2); // confidence "verified", and `$0.00` would still be the wrong thing to print for it. // Sub-figures (per-token-class, $/msg, aggregates over many sessions) keep calling usd(). export const money = v => (v ? usd(v) : "·"); +// A per-node figure with its confidence: "n/a" is an unknown price, never zero, +// and "partial" is a lower bound. The terminal tree and the CLI list use this. +export const costLabel = (v, confidence) => confidence === "n/a" ? "n/a" : (confidence === "partial" ? "≥" : "") + money(v); const num = n => (n ?? 0).toLocaleString("en"); export const fmtDur = s => s == null ? "" : s >= 3600 ? (s / 3600).toFixed(1) + "h" : s >= 60 ? Math.round(s / 60) + "m" : s + "s"; @@ -154,9 +157,12 @@ export function perAgent(root) { (function walk(n, depth) { if (depth) { const p = n.costParts || { in: 0, out: 0, cr: 0, cw: 0 }; + // na / pa: nodes whose own cost is unknown / only a lower bound, so a + // breakdown can say so instead of summing them as $0. const g = by[n.agent] || (by[n.agent] = { c: 0, o: 0, d: [0, 0, 0, 0], ms: 0, - t: [0, 0, 0, 0] }); + t: [0, 0, 0, 0], na: 0, pa: 0 }); g.c++; g.o += n.cost.own; + if (n.cost.ownConfidence === "n/a") g.na++; else if (n.cost.ownConfidence === "partial") g.pa++; g.d[0] += p.in; g.d[1] += p.out; g.d[2] += p.cr; g.d[3] += p.cw; g.t[0] += n.tokens.input; g.t[1] += n.tokens.output; g.t[2] += n.tokens.cacheRead; g.t[3] += n.tokens.cacheWrite5m + n.tokens.cacheWrite1h; @@ -1664,8 +1670,8 @@ export function treeTerminal(tree, width = process.stdout.columns || 120) { const out = []; function line(n, prefix, childPrefix) { const badge = n.via === "cli" ? `[CLI${n.provider !== "claude" ? ":" + n.provider : ""}] ` : ""; - const cost = money(n.cost.total) + - (n.children.length ? ` (own ${money(n.cost.own)} + sub ${money(n.cost.children)})` : "") + + const cost = costLabel(n.cost.total, n.cost.confidence) + + (n.children.length ? ` (own ${costLabel(n.cost.own, n.cost.ownConfidence)} + sub ${money(n.cost.children)})` : "") + (n.cost.confidence === "computed" ? " ±" : n.cost.confidence === "reported" ? " (reported)" : ""); const meta = `${shortModel(n.model)} ${n.effort.join(",")} ${fmtDur(n.durationS)} ${n.apiCalls}c out ${kTok(n.tokens.output)} cR ${kTok(n.tokens.cacheRead)} cW ${kTok(n.tokens.cacheWrite5m + n.tokens.cacheWrite1h)}` + (n.reported?.tokensUsed != null ? ` rep ${num(n.reported.tokensUsed)} tok` : ""); diff --git a/src/session-index.mjs b/src/session-index.mjs index de40b87..e0e12c3 100644 --- a/src/session-index.mjs +++ b/src/session-index.mjs @@ -5,6 +5,14 @@ export const LIVE_MS = 120_000; const TOKEN_KEYS = ["in", "out", "cr", "cw"]; const number = value => Number.isFinite(value) ? value : 0; +function unknownModels(node, out = new Set()) { + for (const m of node.unknownModels || []) out.add(m); + for (const child of node.children) unknownModels(child, out); + return out; +} +// A cost that is not fully known: "n/a" (nothing priced) or "partial" (a lower bound). +const unsure = row => row.costConfidence === "n/a" || row.costConfidence === "partial"; + export function countAgents(node) { return node.children.reduce((total, child) => total + 1 + countAgents(child), 0); } @@ -23,6 +31,7 @@ export function toSessionRow(session, tree, now = Date.now()) { costPerHumanMsg: tree.costPerHumanMsg, callsPerHumanMsg: tree.callsPerHumanMsg, agentMs: agentMs(tree), byAgent: perAgent(tree), model, workspace: w, version: tree.version, tokens: totals.t, tokenCost: totals.d, cost: tree.cost.total, + costConfidence: tree.cost.confidence, unknownModels: [...unknownModels(tree)], }; } @@ -64,25 +73,30 @@ function metric(row, key) { function aggregate(rows) { const token = { in: 0, out: 0, cr: 0, cw: 0 }, tokenCost = { in: 0, out: 0, cr: 0, cw: 0 }; - let cost = 0, agents = 0, calls = 0, agentMs = 0, humanMsgs = 0; + let cost = 0, agents = 0, calls = 0, agentMs = 0, humanMsgs = 0, unpriced = 0; const costs = []; for (const row of rows) { cost += number(row.cost); agents += number(row.agents); calls += number(row.apiCalls); agentMs += number(row.agentMs); humanMsgs += number(row.humanMsgs); costs.push(number(row.cost)); + if (unsure(row)) unpriced++; for (const key of TOKEN_KEYS) { token[key] += number(row.tokens?.[key]); tokenCost[key] += number(row.tokenCost?.[key]); } } costs.sort((a, b) => a - b); - return { cost, agents, calls, agentMs, humanMsgs, token, tokenCost, average: rows.length ? cost / rows.length : null, + // unpriced > 0 makes every cost figure below a lower bound. + return { cost, unpriced, agents, calls, agentMs, humanMsgs, token, tokenCost, average: rows.length ? cost / rows.length : null, median: rows.length ? costs[costs.length >> 1] : null, costPerHumanMsg: humanMsgs ? cost / humanMsgs : null, callsPerHumanMsg: humanMsgs ? calls / humanMsgs : null }; } function breakdown(rows, by, metricKey) { const groups = new Map(); - const add = (key, count, value, tokenCost) => { const prior = groups.get(key) || { key, count: 0, value: 0, tokenCost: { in: 0, out: 0, cr: 0, cw: 0 } }; prior.count += count; prior.value += value; for (const k of TOKEN_KEYS) prior.tokenCost[k] += number(tokenCost?.[k]); groups.set(key, prior); }; + // na / pa count the contributions whose cost is unknown / a lower bound; the + // group's costConfidence is "n/a" when every contribution is unknown. + const add = (key, count, value, tokenCost, na = 0, pa = 0) => { const prior = groups.get(key) || { key, count: 0, value: 0, tokenCost: { in: 0, out: 0, cr: 0, cw: 0 }, na: 0, pa: 0 }; prior.count += count; prior.value += value; prior.na += na; prior.pa += pa; for (const k of TOKEN_KEYS) prior.tokenCost[k] += number(tokenCost?.[k]); groups.set(key, prior); }; + const rowConf = row => [row.costConfidence === "n/a" ? 1 : 0, row.costConfidence === "partial" ? 1 : 0]; for (const row of rows) { if (by === "model" || by === "project") { - const key = by === "project" ? (row.workspace?.label || row.project || "—") : (row.model || "—"); add(key, 1, metric(row, metricKey), row.tokenCost); + const key = by === "project" ? (row.workspace?.label || row.project || "—") : (row.model || "—"); add(key, 1, metric(row, metricKey), row.tokenCost, ...rowConf(row)); continue; } const child = { cost: 0, agents: 0, agentMs: 0, token: { in: 0, out: 0, cr: 0, cw: 0 }, tokenCost: { in: 0, out: 0, cr: 0, cw: 0 } }; @@ -90,18 +104,20 @@ function breakdown(rows, by, metricKey) { child.cost += number(agent.o); child.agents += number(agent.c); child.agentMs += number(agent.ms); for (let i = 0; i < TOKEN_KEYS.length; i++) { const tokenKey = TOKEN_KEYS[i]; child.token[tokenKey] += number(agent.t?.[i]); child.tokenCost[tokenKey] += number(agent.d?.[i]); } const value = metricKey === "cost" ? number(agent.o) : metricKey === "agents" || metricKey === "sessions" ? number(agent.c) : metricKey === "agentMs" ? number(agent.ms) : metricKey === "duration" ? number(agent.ms) / 1000 : metricKey === "calls" || metricKey === "humanMsgs" ? 0 : number(agent.t?.[TOKEN_KEYS.indexOf(metricKey)]); - add(key, number(agent.c), value, Object.fromEntries(TOKEN_KEYS.map((tokenKey, i) => [tokenKey, number(agent.d?.[i])]))); + add(key, number(agent.c), value, Object.fromEntries(TOKEN_KEYS.map((tokenKey, i) => [tokenKey, number(agent.d?.[i])])), number(agent.na), number(agent.pa)); } const remainderTokens = Object.fromEntries(TOKEN_KEYS.map(key => [key, Math.max(0, number(row.tokens?.[key]) - child.token[key])])); const remainderCost = Object.fromEntries(TOKEN_KEYS.map(key => [key, Math.max(0, number(row.tokenCost?.[key]) - child.tokenCost[key])])); const mainValue = metricKey === "cost" ? Math.max(0, number(row.cost) - child.cost) : metricKey === "agents" ? Math.max(0, number(row.agents) - child.agents) : metricKey === "sessions" ? 1 : metricKey === "calls" ? number(row.apiCalls) : metricKey === "humanMsgs" ? number(row.humanMsgs) : metricKey === "duration" ? number(row.durationS) : metricKey === "agentMs" ? Math.max(0, number(row.agentMs) - child.agentMs) : remainderTokens[metricKey]; - if (mainValue || TOKEN_KEYS.some(key => remainderTokens[key] || remainderCost[key])) add("main", 1, mainValue, remainderCost); + if (mainValue || TOKEN_KEYS.some(key => remainderTokens[key] || remainderCost[key])) add("main", 1, mainValue, remainderCost, ...rowConf(row)); } - return [...groups.values()].sort((a, b) => b.value - a.value || a.key.localeCompare(b.key)); + return [...groups.values()].map(({ na, pa, ...g }) => ({ ...g, costConfidence: na && na === g.count ? "n/a" : na || pa ? "partial" : undefined })) + .sort((a, b) => b.value - a.value || a.key.localeCompare(b.key)); } export function querySessions(rows, input) { const q = normalizeSessionQuery(input); - const matched = rows.filter(row => number(row.cost) >= q.minCost && (!q.project || (row.workspace?.label || row.project) === q.project) && + // A cost that is not fully known cannot be ruled below a threshold, so it is never filtered out by one. + const matched = rows.filter(row => (number(row.cost) >= q.minCost || unsure(row)) && (!q.project || (row.workspace?.label || row.project) === q.project) && (!q.model || row.model === q.model) && (!q.live || row.live) && (!row.start || (Date.parse(row.start) >= q.from && Date.parse(row.start) <= q.to)) && (!q.q || haystack(row).includes(q.q))); matched.sort((a, b) => { const av = metric(a, q.sort), bv = metric(b, q.sort); const compared = typeof av === "string" ? av.localeCompare(bv) : av - bv; return q.dir * (compared || a.id.localeCompare(b.id)); }); diff --git a/src/ui/money.ts b/src/ui/money.ts new file mode 100644 index 0000000..42d3bc3 --- /dev/null +++ b/src/ui/money.ts @@ -0,0 +1,24 @@ +// Every dollar figure the UI shows goes through here, so that "unknown" can never +// be rendered as "$0.00". A cost's confidence decides its text: +// "n/a" nothing in it was priced (no price for the model) -> "n/a" +// "partial" some of it was not priced, so the figure is a lower bound -> "≥ $x" +// anything else -> "$x" +export const money = (value?: number | null) => value == null ? "—" : value > 0 && value < .01 ? "$" + value.toFixed(4) : "$" + value.toFixed(2); + +export const costText = (value: number | null | undefined, confidence?: string) => + confidence === "n/a" ? "n/a" : (confidence === "partial" ? "≥ " : "") + money(value); + +type Priced = { cost?: { total?: number; own?: number; confidence?: string; ownConfidence?: string } }; +/** The combined confidence of several costs: n/a only if none is known. */ +export const combined = (confidences: (string | undefined)[]) => + confidences.length && confidences.every(c => c === "n/a") ? "n/a" : confidences.some(c => c === "n/a" || c === "partial") ? "partial" : undefined; + +export const totalText = (node: Priced) => costText(node.cost?.total, node.cost?.confidence); +export const ownText = (node: Priced) => costText(node.cost?.own, node.cost?.ownConfidence ?? node.cost?.confidence); +/** The summed subtree totals of `nodes`. */ +export const sumText = (nodes: Priced[], value = nodes.reduce((sum, node) => sum + (node.cost?.total || 0), 0)) => + costText(value, combined(nodes.map(node => node.cost?.confidence))); + +/** Hover text naming what could not be priced. */ +export const unpricedTitle = (confidence?: string, models?: string[]) => + confidence === "n/a" || confidence === "partial" ? "No price available for: " + ((models || []).join(", ") || "part of the usage") : undefined; diff --git a/src/ui/session-view.tsx b/src/ui/session-view.tsx index c93f50e..2ef323e 100644 --- a/src/ui/session-view.tsx +++ b/src/ui/session-view.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; +import { money, costText, totalText, ownText, sumText, combined, unpricedTitle } from "./money"; -const money = value => value == null ? "—" : value > 0 && value < .01 ? "\$" + value.toFixed(4) : "\$" + value.toFixed(2); const num = value => Number(value || 0).toLocaleString("en"); const duration = value => value == null ? "" : value >= 3600 ? (value / 3600).toFixed(1) + "h" : value >= 60 ? Math.round(value / 60) + "m" : value + "s"; const tokenCount = value => value >= 1e6 ? (value / 1e6).toFixed(1) + "M" : value >= 1e3 ? (value / 1e3).toFixed(0) + "k" : num(value); @@ -15,10 +15,10 @@ function totals(node, depth = 0) { } function flatten(node, depth = 0, parent = null, out = []) { out.push({ node, depth, parent }); (node.children || []).forEach(child => flatten(child, depth + 1, keyOf(node), out)); return out; } function visibleRows(rows: any[], open: Set) { const byKey: Map = new Map(rows.map(row => [keyOf(row.node), row])); return rows.filter(row => { let parent = row.parent; while (parent) { if (!open.has(parent)) return false; parent = byKey.get(parent)?.parent; } return true; }); } -function MetricGrid({ tree, total }) { const metrics = [["total", money(tree.cost?.total)], ["agents", num(flatten(tree).length - 1)], ["calls", num(tree.apiCalls)], ["human messages", num(tree.humanMsgs)], ["agent time", duration(Math.round(total.agentMs / 1000))], ["input", tokenCount(total.token.in), money(total.cost.in)], ["output", tokenCount(total.token.out), money(total.cost.out)], ["cache read", tokenCount(total.token.cr), money(total.cost.cr)], ["cache write", tokenCount(total.token.cw), money(total.cost.cw)]]; return
{metrics.map(([label, value, sub], index) =>
{label}{value}{sub && {sub}}
)}
; } +function MetricGrid({ tree, total }) { const metrics = [["total", totalText(tree)], ["agents", num(flatten(tree).length - 1)], ["calls", num(tree.apiCalls)], ["human messages", num(tree.humanMsgs)], ["agent time", duration(Math.round(total.agentMs / 1000))], ["input", tokenCount(total.token.in), money(total.cost.in)], ["output", tokenCount(total.token.out), money(total.cost.out)], ["cache read", tokenCount(total.token.cr), money(total.cost.cr)], ["cache write", tokenCount(total.token.cw), money(total.cost.cw)]]; return
{metrics.map(([label, value, sub], index) =>
{label}{value}{sub && {sub}}
)}
; } function ownMetric(node, metric) { const token = tokenParts(node).token; if (metric === "cost") return node.cost?.own || 0; if (metric === "calls") return node.apiCalls || 0; if (metric === "agentMs") return node.durationS || 0; return token[metric] || 0; } -function Breakdown({ rows }) { const [by, setBy] = useState("model"), [metric, setMetric] = useState("cost"), [all, setAll] = useState(false); const groups = new Map(); rows.forEach(({ node }) => { const key = by === "model" ? node.model?.[0] || "—" : node.agent || "main"; const entry = groups.get(key) || { key, value: 0, count: 0, tokenCost: { in: 0, out: 0, cr: 0, cw: 0 } }; entry.value += ownMetric(node, metric); entry.count++; const parts = node.costParts || {}; for (const part of ["in", "out", "cr", "cw"]) entry.tokenCost[part] += parts[part] || 0; groups.set(key, entry); }); const result = [...groups.values()].sort((a,b) => b.value - a.value || a.key.localeCompare(b.key)); const actualTotal = result.reduce((sum, row) => sum + row.value, 0), scale = actualTotal || 1; const format = value => metric === "cost" ? money(value) : metric === "agentMs" ? duration(value) : metric === "calls" ? num(value) : tokenCount(value); return
Breakdown
{[["cost", "cost"], ["out", "out"], ["cr", "cache read"], ["cw", "cache write"], ["in", "in"], ["calls", "calls"], ["agentMs", "agent time"]].map(([key, label]) => )}
{format(actualTotal)}
{(all ? result : result.slice(0, 12)).map(row =>
{row.key}×{row.count}{format(row.value)}
)}
{result.length > 12 ? : null}
; } -function AgentTable({ tree }) { const [open, setOpen] = useState(() => new Set([keyOf(tree)])); const [query, setQuery] = useState(""); const [model, setModel] = useState(""); const [agent, setAgent] = useState(""); const [sort, setSort] = useState("start"); const [dir, setDir] = useState("asc"); const rows = flatten(tree), models = [...new Set(rows.flatMap(({ node }) => node.model || []))].sort(), agents = [...new Set(rows.map(({ node }) => node.agent).filter(Boolean))].sort(), byKey = new Map(rows.map(row => [keyOf(row.node), row])); const isMatch = row => (!model || row.node.model?.includes(model)) && (!agent || row.node.agent === agent) && (!query || [row.node.agent, row.node.description, ...(row.node.model || []), ...(row.node.skills || [])].filter(Boolean).join(" ").toLowerCase().includes(query.toLowerCase())); const filtering = Boolean(model || agent || query); const required = new Set(); if (filtering) rows.filter(isMatch).forEach(row => { let current = row; while (current) { required.add(keyOf(current.node)); current = current.parent ? byKey.get(current.parent) : null; } }); let matched = filtering ? rows.filter(row => required.has(keyOf(row.node))) : visibleRows(rows, open); const toggleSort = key => { if (key === sort) setDir(value => value === "asc" ? "desc" : "asc"); else { setSort(key); setDir(key === "agent" || key === "model" ? "asc" : "desc"); } }; const value = row => { const expandable = row.node.children?.length, expanded = open.has(keyOf(row.node)), shown = expanded && expandable ? tokenParts(row.node) : totals(row.node); if (sort === "agent") return row.node.agent || ""; if (sort === "model") return row.node.model?.join(",") || ""; if (sort === "start") return Date.parse(row.node.start || 0); if (sort === "cost") return expanded && expandable ? row.node.cost?.own || 0 : row.node.cost?.total || 0; if (sort === "calls") return row.node.apiCalls || 0; if (["in", "out", "cr", "cw"].includes(sort)) return shown.token[sort] || 0; return row.node.durationS || 0; }; const compare = (a, b) => { const av = value(a), bv = value(b), result = typeof av === "string" ? av.localeCompare(bv) : av - bv; return (dir === "asc" ? 1 : -1) * (result || keyOf(a.node).localeCompare(keyOf(b.node))); }; const children = new Map(); for (const row of matched) { const key = row.parent || "__root__"; const list = children.get(key) || []; list.push(row); children.set(key, list); } const ordered = []; const walk = parent => { for (const row of (children.get(parent || "__root__") || []).sort(compare)) { ordered.push(row); walk(keyOf(row.node)); } }; walk(null); matched = ordered; const head = (label, key, numeric = false) => toggleSort(key)}>{label}{sort === key ? dir === "asc" ? "▲" : "▼" : "⇅"}; return <>
setQuery(event.target.value)} />collapsed = subtree total · expanded = own cost
{head("agent", "agent")}{head("model", "model")}{head("started", "start")}{head("dur", "duration", true)}{head("calls", "calls", true)}{head("in", "in", true)}{head("out", "out", true)}{head("cache read", "cr", true)}{head("cache write", "cw", true)}{head("cost", "cost", true)}{matched.map(({ node, depth }) => { const key = keyOf(node), expandable = node.children?.length, expanded = open.has(key), aggregate = totals(node), shown = expanded && expandable ? tokenParts(node) : aggregate; return ; })}
{node.via === "cli" && CLI{node.provider !== "claude" ? " · " + node.provider : ""}}{node.agent}
{node.description}{node.skills?.length ? " · ⚙ " + node.skills.slice(0,3).join(" → ") : ""}
{node.model?.join(",") || "—"}{shortDate(node.start)}{duration(node.durationS)}{num(node.apiCalls)}{tokenCount(shown.token.in)}{tokenCount(shown.token.out)}{tokenCount(shown.token.cr)}{tokenCount(shown.token.cw)}{money(expanded && expandable ? node.cost?.own : node.cost?.total)}
; } +function Breakdown({ rows }) { const [by, setBy] = useState("model"), [metric, setMetric] = useState("cost"), [all, setAll] = useState(false); const groups = new Map(); rows.forEach(({ node }) => { const key = by === "model" ? node.model?.[0] || "—" : node.agent || "main"; const entry = groups.get(key) || { key, value: 0, count: 0, tokenCost: { in: 0, out: 0, cr: 0, cw: 0 }, conf: [] }; entry.value += ownMetric(node, metric); entry.count++; entry.conf.push(node.cost?.ownConfidence); const parts = node.costParts || {}; for (const part of ["in", "out", "cr", "cw"]) entry.tokenCost[part] += parts[part] || 0; groups.set(key, entry); }); const result = [...groups.values()].sort((a,b) => b.value - a.value || a.key.localeCompare(b.key)); const actualTotal = result.reduce((sum, row) => sum + row.value, 0), scale = actualTotal || 1; const format = (value, conf?) => metric === "cost" ? costText(value, conf) : metric === "agentMs" ? duration(value) : metric === "calls" ? num(value) : tokenCount(value); return
Breakdown
{[["cost", "cost"], ["out", "out"], ["cr", "cache read"], ["cw", "cache write"], ["in", "in"], ["calls", "calls"], ["agentMs", "agent time"]].map(([key, label]) => )}
{format(actualTotal, combined(result.flatMap(row => row.conf)))}
{(all ? result : result.slice(0, 12)).map(row =>
{row.key}×{row.count}{format(row.value, combined(row.conf))}
)}
{result.length > 12 ? : null}
; } +function AgentTable({ tree }) { const [open, setOpen] = useState(() => new Set([keyOf(tree)])); const [query, setQuery] = useState(""); const [model, setModel] = useState(""); const [agent, setAgent] = useState(""); const [sort, setSort] = useState("start"); const [dir, setDir] = useState("asc"); const rows = flatten(tree), models = [...new Set(rows.flatMap(({ node }) => node.model || []))].sort(), agents = [...new Set(rows.map(({ node }) => node.agent).filter(Boolean))].sort(), byKey = new Map(rows.map(row => [keyOf(row.node), row])); const isMatch = row => (!model || row.node.model?.includes(model)) && (!agent || row.node.agent === agent) && (!query || [row.node.agent, row.node.description, ...(row.node.model || []), ...(row.node.skills || [])].filter(Boolean).join(" ").toLowerCase().includes(query.toLowerCase())); const filtering = Boolean(model || agent || query); const required = new Set(); if (filtering) rows.filter(isMatch).forEach(row => { let current = row; while (current) { required.add(keyOf(current.node)); current = current.parent ? byKey.get(current.parent) : null; } }); let matched = filtering ? rows.filter(row => required.has(keyOf(row.node))) : visibleRows(rows, open); const toggleSort = key => { if (key === sort) setDir(value => value === "asc" ? "desc" : "asc"); else { setSort(key); setDir(key === "agent" || key === "model" ? "asc" : "desc"); } }; const value = row => { const expandable = row.node.children?.length, expanded = open.has(keyOf(row.node)), shown = expanded && expandable ? tokenParts(row.node) : totals(row.node); if (sort === "agent") return row.node.agent || ""; if (sort === "model") return row.node.model?.join(",") || ""; if (sort === "start") return Date.parse(row.node.start || 0); if (sort === "cost") return expanded && expandable ? row.node.cost?.own || 0 : row.node.cost?.total || 0; if (sort === "calls") return row.node.apiCalls || 0; if (["in", "out", "cr", "cw"].includes(sort)) return shown.token[sort] || 0; return row.node.durationS || 0; }; const compare = (a, b) => { const av = value(a), bv = value(b), result = typeof av === "string" ? av.localeCompare(bv) : av - bv; return (dir === "asc" ? 1 : -1) * (result || keyOf(a.node).localeCompare(keyOf(b.node))); }; const children = new Map(); for (const row of matched) { const key = row.parent || "__root__"; const list = children.get(key) || []; list.push(row); children.set(key, list); } const ordered = []; const walk = parent => { for (const row of (children.get(parent || "__root__") || []).sort(compare)) { ordered.push(row); walk(keyOf(row.node)); } }; walk(null); matched = ordered; const head = (label, key, numeric = false) => toggleSort(key)}>{label}{sort === key ? dir === "asc" ? "▲" : "▼" : "⇅"}; return <>
setQuery(event.target.value)} />collapsed = subtree total · expanded = own cost
{head("agent", "agent")}{head("model", "model")}{head("started", "start")}{head("dur", "duration", true)}{head("calls", "calls", true)}{head("in", "in", true)}{head("out", "out", true)}{head("cache read", "cr", true)}{head("cache write", "cw", true)}{head("cost", "cost", true)}{matched.map(({ node, depth }) => { const key = keyOf(node), expandable = node.children?.length, expanded = open.has(key), aggregate = totals(node), shown = expanded && expandable ? tokenParts(node) : aggregate; return ; })}
{node.via === "cli" && CLI{node.provider !== "claude" ? " · " + node.provider : ""}}{node.agent}
{node.description}{node.skills?.length ? " · ⚙ " + node.skills.slice(0,3).join(" → ") : ""}
{node.model?.join(",") || "—"}{shortDate(node.start)}{duration(node.durationS)}{num(node.apiCalls)}{tokenCount(shown.token.in)}{tokenCount(shown.token.out)}{tokenCount(shown.token.cr)}{tokenCount(shown.token.cw)}{expanded && expandable ? ownText(node) : totalText(node)}
; } function tokenParts(node) { const t = node.tokens || {}, d = node.costParts || {}; return { token: { in: t.input || 0, out: t.output || 0, cr: t.cacheRead || 0, cw: (t.cacheWrite5m || 0) + (t.cacheWrite1h || 0) }, cost: { in: d.in || 0, out: d.out || 0, cr: d.cr || 0, cw: d.cw || 0 } }; @@ -39,30 +39,30 @@ function wavesOf(nodes) { } function FlowCard({ node, isMain = false, open, toggle, onCalls }) { const children = node.children || [], own = tokenParts(node), all = totals(node), human = (node.calls || []).filter(call => call.human || call.gates || call.flags & 1).length; - return
children.length && toggle(keyOf(node))} title={node.agent + " · " + duration(node.durationS) + " · " + money(node.cost?.total)}> -
{node.via === "cli" && CLI}{node.agent}{money(node.cost?.own)}
+ return
children.length && toggle(keyOf(node))} title={node.agent + " · " + duration(node.durationS) + " · " + totalText(node)}> +
{node.via === "cli" && CLI}{node.agent}{ownText(node)}
{isMain ? "orchestration — runs start to finish, launching the stages below and handling what comes back" : node.description || "—"}
{node.model?.join(",") || "—"}{node.effort && {node.effort}}{duration(node.durationS)}{(node.calls || []).length ? : {num(node.apiCalls)} calls}{human ? {human} human : null}
{when(node.start, node.end)}
- {children.length ?
{open ? "▾" : "▸"} {children.length} subagent{children.length === 1 ? "" : "s"}{money((node.cost?.total || 0) - (node.cost?.own || 0))}
: null} + {children.length ?
{open ? "▾" : "▸"} {children.length} subagent{children.length === 1 ? "" : "s"}{sumText(children)}
: null}
; } function FlowLevel({ nodes, open, toggle, onCalls }) { const waves = wavesOf(nodes); let previousEnd = null; - return <>{waves.map((wave, index) => { const start = Math.min(...wave.map(node => Date.parse(node.start))), end = Math.max(...wave.map(node => Date.parse(node.end))); const totalsForWave = wave.reduce((sum, node) => { const all = totals(node); for (const key of ["in", "out", "cr", "cw"]) { sum.token[key] += all.token[key]; sum.cost[key] += all.cost[key]; } sum.total += node.cost?.total || 0; return sum; }, { token: { in: 0, out: 0, cr: 0, cw: 0 }, cost: { in: 0, out: 0, cr: 0, cw: 0 }, total: 0 }); const gap = previousEnd == null ? null : start - previousEnd; previousEnd = end; return {gap > 0 &&
{duration(Math.round(gap / 1000))} idle
}
{index + 1}{duration(Math.round((end - start) / 1000))}{when(new Date(start).toISOString(), new Date(end).toISOString())}{wave.length} {wave.length === 1 ? "agent" : "in parallel"}{wave.reduce((count, node) => count + descendants(node), 0) ? +{wave.reduce((count, node) => count + descendants(node), 0)} nested : null}{money(totalsForWave.total)}
{wave.map(node => { const expanded = open.has(keyOf(node)); return {expanded && node.children?.length ?
subagents of {node.agent}{descendants(node)} in total{money((node.cost?.total || 0) - (node.cost?.own || 0))}
: null}
; })}
; })}; + return <>{waves.map((wave, index) => { const start = Math.min(...wave.map(node => Date.parse(node.start))), end = Math.max(...wave.map(node => Date.parse(node.end))); const totalsForWave = wave.reduce((sum, node) => { const all = totals(node); for (const key of ["in", "out", "cr", "cw"]) { sum.token[key] += all.token[key]; sum.cost[key] += all.cost[key]; } sum.total += node.cost?.total || 0; sum.conf.push(node.cost?.confidence); return sum; }, { token: { in: 0, out: 0, cr: 0, cw: 0 }, cost: { in: 0, out: 0, cr: 0, cw: 0 }, total: 0, conf: [] }); const gap = previousEnd == null ? null : start - previousEnd; previousEnd = end; return {gap > 0 &&
{duration(Math.round(gap / 1000))} idle
}
{index + 1}{duration(Math.round((end - start) / 1000))}{when(new Date(start).toISOString(), new Date(end).toISOString())}{wave.length} {wave.length === 1 ? "agent" : "in parallel"}{wave.reduce((count, node) => count + descendants(node), 0) ? +{wave.reduce((count, node) => count + descendants(node), 0)} nested : null}{costText(totalsForWave.total, combined(totalsForWave.conf))}
{wave.map(node => { const expanded = open.has(keyOf(node)); return {expanded && node.children?.length ?
subagents of {node.agent}{descendants(node)} in total{sumText(node.children)}
: null}
; })}
; })}; } function FlowView({ tree, onCalls }) { const [open, setOpen] = useState(() => new Set()); const toggle = key => setOpen(current => { const next = new Set(current); next.has(key) ? next.delete(key) : next.add(key); return next; }); const children = tree.children || [], stages = wavesOf(children), childCost = children.reduce((sum, node) => sum + (node.cost?.total || 0), 0); - return
{children.length ? <>
{descendants(tree)} subagent{descendants(tree) === 1 ? "" : "s"}, {stages.length} stage{stages.length === 1 ? "" : "s"} at the top level
{descendants(tree)} agents{money(tree.cost?.own)} main + {money(childCost)} agents = {money((tree.cost?.own || 0) + childCost)}
:

No subagents in this session.

}
; + return
{children.length ? <>
{descendants(tree)} subagent{descendants(tree) === 1 ? "" : "s"}, {stages.length} stage{stages.length === 1 ? "" : "s"} at the top level
{descendants(tree)} agents{ownText(tree)} main + {sumText(children, childCost)} agents = {totalText(tree)}
:

No subagents in this session.

}
; } function TraceView({ tree }) { const [open, setOpen] = useState(() => new Set([keyOf(tree)])); const fullStart = Date.parse(tree.start || 0), fullEnd = Math.max(fullStart + 1, Date.parse(tree.end || tree.start || 0)); const [windowRange, setWindowRange] = useState([fullStart, fullEnd]); const [manualZoom, setManualZoom] = useState(false); const [drag, setDrag] = useState(null); useEffect(() => { if (!manualZoom) setWindowRange([fullStart, fullEnd]); else setWindowRange(([from, to]) => [Math.max(fullStart, from), Math.min(fullEnd, to)]); }, [fullStart, fullEnd, manualZoom]); const trackRef = useRef(null); const rows = visibleRows(flatten(tree), open); const [start, end] = windowRange, span = Math.max(1, end - start), zoomed = start !== fullStart || end !== fullEnd; const at = event => { const rect = trackRef.current?.getBoundingClientRect(); return rect ? Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)) : 0; }; const finishDrag = event => { if (!drag) return; const b = at(event), low = Math.min(drag.start, b), high = Math.max(drag.start, b); setDrag(null); if (high - low > .005) { setManualZoom(true); setWindowRange(([from, to]) => { const width = to - from; return [from + width * low, from + width * high]; }); } }; - return
{ if (drag != null) { event.preventDefault(); setDrag(current => current ? { ...current, current: at(event) } : null); } }} onPointerUp={finishDrag} onPointerCancel={() => setDrag(null)}>
{zoomed ? : drag to zoom}
{ if ((event.target as Element).closest(".tl-name")) return; event.currentTarget.setPointerCapture(event.pointerId); setDrag({ start: at(event), current: at(event) }); }}>{[0, 1, 2, 3, 4, 5].map(i => {duration(Math.round((start - fullStart + span * i / 5) / 1000))})}
{drag != null &&
}{rows.map(({ node, depth }) => { const key = keyOf(node), children = node.children || [], nodeStart = Date.parse(node.start || 0), nodeEnd = Date.parse(node.end || node.start || 0); if (nodeEnd < start || nodeStart > end) return null; const left = Math.max(0, (Math.max(nodeStart, start) - start) / span * 100), width = Math.max(.3, (Math.min(nodeEnd, end) - Math.max(nodeStart, start)) / span * 100); return
{duration(node.durationS)} · {money(node.cost?.total)}
; })}
; + return
{ if (drag != null) { event.preventDefault(); setDrag(current => current ? { ...current, current: at(event) } : null); } }} onPointerUp={finishDrag} onPointerCancel={() => setDrag(null)}>
{zoomed ? : drag to zoom}
{ if ((event.target as Element).closest(".tl-name")) return; event.currentTarget.setPointerCapture(event.pointerId); setDrag({ start: at(event), current: at(event) }); }}>{[0, 1, 2, 3, 4, 5].map(i => {duration(Math.round((start - fullStart + span * i / 5) / 1000))})}
{drag != null &&
}{rows.map(({ node, depth }) => { const key = keyOf(node), children = node.children || [], nodeStart = Date.parse(node.start || 0), nodeEnd = Date.parse(node.end || node.start || 0); if (nodeEnd < start || nodeStart > end) return null; const left = Math.max(0, (Math.max(nodeStart, start) - start) / span * 100), width = Math.max(.3, (Math.min(nodeEnd, end) - Math.max(nodeStart, start)) / span * 100); return
{duration(node.durationS)} · {totalText(node)}
; })}
; } function callType(call) { if (call.acts?.some(action => action.skill)) return "skill"; if (call.acts?.some(action => action.name === "Task" || action.name === "Agent")) return "agent"; return call.acts?.length ? "tool" : "text only"; } function callName(call) { return (call.acts || []).map(action => action.skill || (action.task ? action.name + " · " + action.task : action.name)).filter(Boolean).join(", ") || "—"; } -function CallDialog({ node, close }) { const [sort, setSort] = useState("ts"); const [filter, setFilter] = useState(""); const [showQuestions, setShowQuestions] = useState(false); useEffect(() => { const handler = event => event.key === "Escape" && close(); addEventListener("keydown", handler); return () => removeEventListener("keydown", handler); }, [close]); if (!node) return null; const all = node.calls || []; const count = type => all.filter(call => callType(call) === type).length, human = all.filter(call => call.opensHuman).length, gates = all.reduce((total, call) => total + (call.gates || 0), 0); const calls = [...all].filter(call => !filter || (filter === "human" ? call.opensHuman : callType(call) === filter)).sort((a,b) => sort === "tokens" ? ((b.tokens?.in || 0) + (b.tokens?.out || 0)) - ((a.tokens?.in || 0) + (a.tokens?.out || 0)) : sort === "cost" ? (b.cost || 0) - (a.cost || 0) : String(a.ts || "").localeCompare(String(b.ts || ""))); return
event.currentTarget === event.target && close()}>

{node.agent} — calls

{[["tool", "tools"], ["skill", "skills"], ["agent", "agents"], ["text only", "text only"], ["human", "human"]].map(([key, label]) => { const value = key === "human" ? human : count(key); return value ? : null; })}{gates ? : null}{filter ? calls.length + " of " + all.length + " calls" : gates ? gates + " decision" + (gates === 1 ? "" : "s") : ""}
{showQuestions && node.interactions?.length ?
{node.interactions.map((interaction, index) =>
{index + 1}
{interaction.questions?.map((question, questionIndex) =>
{question.header || "Question"}{question.question}{interaction.answers?.[questionIndex] ?
{interaction.answers[questionIndex]}
: null}
)}
)}
: null}
{calls.map((call, index) => )}{!calls.length && }
#startedtypecall setSort("cost")}>costinoutcache readcache write
{all.indexOf(call) + 1}{shortDate(call.ts)}{callType(call)}{callName(call)}{call.opensHuman ? human{call.gates ? " · " + call.gates + " decision" + (call.gates === 1 ? "" : "s") : ""} : null}{money(call.cost)}{tokenCount(call.tokens?.in)}{tokenCount(call.tokens?.out)}{tokenCount(call.tokens?.cr)}{tokenCount(call.tokens?.cw)}
No calls recorded for this node.
; } +function CallDialog({ node, close }) { const [sort, setSort] = useState("ts"); const [filter, setFilter] = useState(""); const [showQuestions, setShowQuestions] = useState(false); useEffect(() => { const handler = event => event.key === "Escape" && close(); addEventListener("keydown", handler); return () => removeEventListener("keydown", handler); }, [close]); if (!node) return null; const all = node.calls || []; const count = type => all.filter(call => callType(call) === type).length, human = all.filter(call => call.opensHuman).length, gates = all.reduce((total, call) => total + (call.gates || 0), 0); const calls = [...all].filter(call => !filter || (filter === "human" ? call.opensHuman : callType(call) === filter)).sort((a,b) => sort === "tokens" ? ((b.tokens?.in || 0) + (b.tokens?.out || 0)) - ((a.tokens?.in || 0) + (a.tokens?.out || 0)) : sort === "cost" ? (b.cost || 0) - (a.cost || 0) : String(a.ts || "").localeCompare(String(b.ts || ""))); return
event.currentTarget === event.target && close()}>

{node.agent} — calls

{[["tool", "tools"], ["skill", "skills"], ["agent", "agents"], ["text only", "text only"], ["human", "human"]].map(([key, label]) => { const value = key === "human" ? human : count(key); return value ? : null; })}{gates ? : null}{filter ? calls.length + " of " + all.length + " calls" : gates ? gates + " decision" + (gates === 1 ? "" : "s") : ""}
{showQuestions && node.interactions?.length ?
{node.interactions.map((interaction, index) =>
{index + 1}
{interaction.questions?.map((question, questionIndex) =>
{question.header || "Question"}{question.question}{interaction.answers?.[questionIndex] ?
{interaction.answers[questionIndex]}
: null}
)}
)}
: null}
{calls.map((call, index) => )}{!calls.length && }
#startedtypecall setSort("cost")}>costinoutcache readcache write
{all.indexOf(call) + 1}{shortDate(call.ts)}{callType(call)}{callName(call)}{call.opensHuman ? human{call.gates ? " · " + call.gates + " decision" + (call.gates === 1 ? "" : "s") : ""} : null}{costText(call.cost, call.cost == null ? "n/a" : undefined)}{tokenCount(call.tokens?.in)}{tokenCount(call.tokens?.out)}{tokenCount(call.tokens?.cr)}{tokenCount(call.tokens?.cw)}
No calls recorded for this node.
; } export function SessionDetail({ tree, info = {}, workspace = {}, live = false, backHref = "/" }: any) { const [view, setView] = useState(() => new URLSearchParams(location.search).get("view") || "flow"); const [callKey, setCallKey] = useState(null); const total = useMemo(() => totals(tree), [tree]); const rows = useMemo(() => flatten(tree), [tree]); const selectedNode = callKey ? rows.find(row => keyOf(row.node) === callKey)?.node || null : null; return
← all sessions

{live && LIVE}{info.title || tree.agent}

{workspace.label}{workspace.branch ? " · " + workspace.branch : ""}

{tree.firstPrompt &&
{tree.firstPrompt}
}
setCallKey(null)} />
; } diff --git a/test/browser.test.mjs b/test/browser.test.mjs index af4e297..7e8b4a4 100644 --- a/test/browser.test.mjs +++ b/test/browser.test.mjs @@ -7,6 +7,9 @@ import { once } from "node:events"; import { chromium } from "@playwright/test"; import { serve } from "../src/server.mjs"; +// The index worker refreshes prices in the background; a test must not touch the network. +process.env.AGENT_ATLAS_PRICING = "offline"; + const id = "11111111-1111-4111-8111-111111111111"; const line = value => JSON.stringify(value) + "\n"; function fixture(root) { diff --git a/test/fixtures/anthropic-pricing.md b/test/fixtures/anthropic-pricing.md new file mode 100644 index 0000000..603e79d --- /dev/null +++ b/test/fixtures/anthropic-pricing.md @@ -0,0 +1,44 @@ + +## Model pricing + +The following table shows pricing for all Claude models: + +| Model | Base input tokens | 5m cache writes | 1h cache writes | Cache hits and refreshes | Output tokens | +| :------------------------------------------------------------------------------------------------------------------------------------ | :-------------------- | :-------------- | :-------------- | :----------------------- | :--------------------- | +| Claude Fable 5.1 | $10 / MTok | $12.50 / MTok | $20 / MTok | $0.25 / MTok1 | $50 / MTok | +| Claude Mythos 5.1 ([limited availability](https://anthropic.com/glasswing)) | $10 / MTok | $12.50 / MTok | $20 / MTok | $0.25 / MTok1 | $50 / MTok | +| Claude Fable 5 | $10 / MTok | $12.50 / MTok | $20 / MTok | $1 / MTok | $50 / MTok | +| Claude Mythos 5 ([limited availability](https://anthropic.com/glasswing)) | $10 / MTok | $12.50 / MTok | $20 / MTok | $1 / MTok | $50 / MTok | +| Claude Opus 5.5 | $4 / MTok | $5 / MTok | $8 / MTok | $0.20 / MTok2 | $20 / MTok | +| Claude Opus 5 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.8 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.7 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.6 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.5 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.1 ([retired, except on Bedrock and Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $15 / MTok | $18.75 / MTok | $30 / MTok | $1.50 / MTok | $75 / MTok | +| Claude Opus 4 ([retired, except on Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $15 / MTok | $18.75 / MTok | $30 / MTok | $1.50 / MTok | $75 / MTok | +| Claude Sonnet 5.5 | $2 / MTok | $2.50 / MTok | $4 / MTok | $0.20 / MTok | $10 / MTok | +| Claude Sonnet 5 | $2 / MTok3 | $2.50 / MTok | $4 / MTok | $0.20 / MTok | $10 / MTok3 | +| Claude Sonnet 4.6 | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | +| Claude Sonnet 4.5 | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | +| Claude Sonnet 4 ([retired, except on Bedrock and Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | +| Claude Haiku 4.5 | $1 / MTok | $1.25 / MTok | $2 / MTok | $0.10 / MTok | $5 / MTok | +| Claude Haiku 3.5 ([retired, except on Bedrock and Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $0.80 / MTok | $1 / MTok | $1.60 / MTok | $0.08 / MTok | $4 / MTok | + +*1 Cache hits and refreshes on Claude Fable 5.1 and Claude Mythos 5.1 are priced at 0.025x the base input price.* + +*2 Cache hits and refreshes on Claude Opus 5.5 are priced at 0.05x the base input price.* + +*All other models use the standard 0.1x multiplier.* + +*3 The $2/$10 per million input/output token pricing for Claude Sonnet 5, announced at launch as introductory pricing through August 31, 2026, is now the standard price. The previously scheduled increase to $3/$15 per million input/output tokens on September 1, 2026 will not occur.* + +* **MTok:** Million tokens. $5 / MTok is $5 for every million tokens. +* **5m cache writes:** Writing a prompt prefix to the 5-minute prompt cache. +* **1h cache writes:** Writing a prompt prefix to the 1-hour prompt cache. +* **Cache hits and refreshes:** Reading a prompt prefix from the prompt cache, which also refreshes it. +* **Limited access:** Offered separately, by invitation only, as part of [Project Glasswing](https://anthropic.com/glasswing). For access, contact your Anthropic, AWS, or Google Cloud account team. +* **Retired:** May still be available on other cloud platforms. See [Model deprecations](https://platform.claude.com/docs/en/about-claude/model-deprecations) for more. + +Claude 4.7 and later models and Claude Mythos Preview use a newer tokenizer that contributes to their improved performance on a wide range of tasks. This tokenizer produces approximately 30% more tokens for the same text. The exact increase depends on the content and workload shape. Claude Sonnet 4.6 and earlier models use the previous tokenizer. + diff --git a/test/fixtures/openai-pricing.md b/test/fixtures/openai-pricing.md new file mode 100644 index 0000000..068f7ab --- /dev/null +++ b/test/fixtures/openai-pricing.md @@ -0,0 +1,53 @@ + +# Pricing + +### Standard pricing data + +| Model | Short context input | Short context cached input | Short context cache writes | Short context output | Long context input | Long context cached input | Long context cache writes | Long context output | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| gpt-6-astra | $10.00 | $1.00 | $12.50 | $50.00 | $20.00 | $2.00 | $25.00 | $75.00 | +| gpt-6.1-sol | $2.00 | $0.10 | $2.50 | $10.00 | $4.00 | $0.20 | $5.00 | $15.00 | +| gpt-6-luna | $0.10 | $0.01 | $0.125 | $0.50 | $0.20 | $0.02 | $0.25 | $0.75 | +| gpt-6-sol | $2.00 | $0.20 | $2.50 | $10.00 | $4.00 | $0.40 | $5.00 | $15.00 | +| gpt-5.6-sol | $4.00 | $0.40 | $5.00 | $20.00 | $8.00 | $0.80 | $10.00 | $30.00 | +| gpt-5.6-terra | $2.00 | $0.20 | $2.50 | $12.00 | $4.00 | $0.40 | $5.00 | $18.00 | +| gpt-5.6-luna | $0.20 | $0.02 | $0.25 | $1.20 | $0.40 | $0.04 | $0.50 | $1.80 | +| gpt-5.5 (<272K context length) | $5.00 | $0.50 | - | $30.00 | $10.00 | $1.00 | - | $45.00 | +| gpt-5.5-pro (<272K context length) | $30.00 | - | - | $180.00 | $60.00 | - | - | $270.00 | +| gpt-5.4 (<272K context length) | $2.50 | $0.25 | - | $15.00 | $5.00 | $0.50 | - | $22.50 | +| gpt-5.4-mini | $0.75 | $0.075 | - | $4.50 | - | - | - | - | +| gpt-5.4-nano | $0.20 | $0.02 | - | $1.25 | - | - | - | - | +| gpt-5.4-pro (<272K context length) | $30.00 | - | - | $180.00 | $60.00 | - | - | $270.00 | +| gpt-5.2 | $1.75 | $0.175 | - | $14.00 | - | - | - | - | +| gpt-5.2-pro | $21.00 | - | - | $168.00 | - | - | - | - | +| gpt-5.1 | $1.25 | $0.125 | - | $10.00 | - | - | - | - | +| gpt-5 | $1.25 | $0.125 | - | $10.00 | - | - | - | - | +| gpt-5-mini | $0.25 | $0.025 | - | $2.00 | - | - | - | - | +| gpt-5-nano | $0.05 | $0.005 | - | $0.40 | - | - | - | - | +| gpt-5-pro | $15.00 | - | - | $120.00 | - | - | - | - | +| gpt-4.1 | $2.00 | $0.50 | - | $8.00 | - | - | - | - | +| gpt-4.1-mini | $0.40 | $0.10 | - | $1.60 | - | - | - | - | +| gpt-4.1-nano | $0.10 | $0.025 | - | $0.40 | - | - | - | - | +| gpt-4o | $2.50 | $1.25 | - | $10.00 | - | - | - | - | +| gpt-4o-2024-05-13 | $5.00 | - | - | $15.00 | - | - | - | - | +| gpt-4o-mini | $0.15 | $0.075 | - | $0.60 | - | - | - | - | +| o1 | $15.00 | $7.50 | - | $60.00 | - | - | - | - | +| o1-pro | $150.00 | - | - | $600.00 | - | - | - | - | +| o3-pro | $20.00 | - | - | $80.00 | - | - | - | - | +| o3 | $2.00 | $0.50 | - | $8.00 | - | - | - | - | +| o4-mini | $1.10 | $0.275 | - | $4.40 | - | - | - | - | +| o3-mini | $1.10 | $0.55 | - | $4.40 | - | - | - | - | +| gpt-4-turbo-2024-04-09 | $10.00 | - | - | $30.00 | - | - | - | - | +| gpt-4-0613 | $30.00 | - | - | $60.00 | - | - | - | - | +| gpt-3.5-turbo | $0.50 | - | - | $1.50 | - | - | - | - | +| gpt-3.5-turbo-0125 | $0.50 | - | - | $1.50 | - | - | - | - | +| gpt-3.5-turbo-1106 | $1.00 | - | - | $2.00 | - | - | - | - | +| gpt-3.5-turbo-instruct | $1.50 | - | - | $2.00 | - | - | - | - | +| davinci-002 | $2.00 | - | - | $2.00 | - | - | - | - | +| babbage-002 | $0.40 | - | - | $0.40 | - | - | - | - | + + + + + + diff --git a/test/pricing.test.mjs b/test/pricing.test.mjs new file mode 100644 index 0000000..aeb269a --- /dev/null +++ b/test/pricing.test.mjs @@ -0,0 +1,219 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Never touch the network or the user's cache from a test. +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agent-atlas-pricing-")); +process.env.AGENT_ATLAS_PRICES_CACHE = path.join(tmp, "prices.json"); +process.env.AGENT_ATLAS_PRICING = "offline"; +test.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + +const pricing = await import("../src/pricing/index.mjs"); +const { parseAnthropic, parseOpenAI } = await import("../src/pricing/sources.mjs"); +const { costLabel } = await import("../src/render.mjs"); +const ui = await import("../src/ui/money.ts"); + +const U = (u = {}) => ({ in: 0, out: 0, cr: 0, cw: 0, c5: 0, c1h: 0, ...u }); +const M = 1e6; +const rates = (vendor, model, ts) => { + const e = pricing.resolve(vendor, model, ts); + return e && [e.rates.in, e.rates.out, e.rates.cr, e.rates.c5, e.rates.c1h]; +}; +const resetCache = () => { fs.rmSync(process.env.AGENT_ATLAS_PRICES_CACHE, { force: true }); pricing.reload(); }; + +// --- Known dated prices, pinned. Source: the official pages as bundled in +// src/pricing/snapshot.json, retrieved 2026-09-29. [in, out, cacheRead, write5m, write1h] $/MTok. +test("bundled snapshot pins Anthropic list prices (retrieved 2026-09-29)", () => { + resetCache(); + const at = "2026-09-29T12:00:00Z"; + assert.deepEqual(rates("anthropic", "claude-opus-5-5", at), [4, 20, 0.2, 5, 8]); + assert.deepEqual(rates("anthropic", "claude-opus-5", at), [5, 25, 0.5, 6.25, 10]); + assert.deepEqual(rates("anthropic", "claude-fable-5-1", at), [10, 50, 0.25, 12.5, 20]); + assert.deepEqual(rates("anthropic", "claude-fable-5", at), [10, 50, 1, 12.5, 20]); + assert.deepEqual(rates("anthropic", "claude-sonnet-5-5", at), [2, 10, 0.2, 2.5, 4]); + assert.deepEqual(rates("anthropic", "claude-sonnet-5", at), [2, 10, 0.2, 2.5, 4]); + assert.deepEqual(rates("anthropic", "claude-haiku-4-5", at), [1, 5, 0.1, 1.25, 2]); + assert.deepEqual(rates("anthropic", "claude-opus-4-8", at), [5, 25, 0.5, 6.25, 10]); +}); + +test("Sonnet 5 stays at $2/$10 after 2026-09-01: the scheduled $3/$15 increase never happened", () => { + resetCache(); + assert.deepEqual(rates("anthropic", "claude-sonnet-5", "2026-08-15T00:00:00Z"), [2, 10, 0.2, 2.5, 4]); + assert.deepEqual(rates("anthropic", "claude-sonnet-5", "2026-09-15T00:00:00Z"), [2, 10, 0.2, 2.5, 4]); +}); + +test("bundled snapshot pins OpenAI (Codex) list prices (retrieved 2026-09-29)", () => { + resetCache(); + const at = "2026-09-29T12:00:00Z"; + assert.deepEqual(rates("openai", "gpt-6-astra", at), [10, 50, 1, 12.5, 12.5]); + assert.deepEqual(rates("openai", "gpt-6-sol", at), [2, 10, 0.2, 2.5, 2.5]); + assert.deepEqual(rates("openai", "gpt-6-luna", at), [0.1, 0.5, 0.01, 0.125, 0.125]); + assert.deepEqual(rates("openai", "gpt-5.6-sol", at), [4, 20, 0.4, 5, 5]); + assert.deepEqual(rates("openai", "gpt-5.6-terra", at), [2, 12, 0.2, 2.5, 2.5]); + assert.deepEqual(rates("openai", "gpt-5.6-luna", at), [0.2, 1.2, 0.02, 0.25, 0.25]); + // gpt-5.5 publishes no cache-write rate: it stays null, never a guessed 1.25x. + assert.deepEqual(rates("openai", "gpt-5.5", at), [5, 30, 0.5, null, null]); +}); + +test("a call is priced per token class with the rates in force, with provenance", () => { + resetCache(); + const r = pricing.priceUsage("anthropic", "claude-opus-5-5", "2026-09-29T12:00:00Z", U({ in: M, out: M, cr: M, c5: M, c1h: M })); + assert.equal(r.confidence, "computed"); + assert.deepEqual(r.parts, { in: 4, out: 20, cr: 0.2, cw: 13 }); + assert.equal(r.usd, 37.2); + const p = pricing.provenance(r.price); + assert.equal(p.vendor, "anthropic"); assert.equal(p.model, "claude-opus-5-5"); assert.equal(p.currency, "USD"); + assert.equal(p.source.kind, "bundled"); assert.equal(p.source.url, "https://platform.claude.com/docs/en/about-claude/pricing"); + assert.deepEqual(p.rates, { in: 4, out: 20, cr: 0.2, c5: 5, c1h: 8 }); + // Models whose arithmetic was reproduced against the harness's own total keep "verified". + assert.equal(pricing.priceUsage("anthropic", "claude-opus-5", null, U({ in: 1 })).confidence, "verified"); +}); + +test("decorated transcript ids resolve to the listed model", () => { + resetCache(); + assert.deepEqual(rates("anthropic", "claude-haiku-4-5-20251001"), [1, 5, 0.1, 1.25, 2]); + assert.deepEqual(rates("anthropic", "claude-sonnet-5[1m]"), [2, 10, 0.2, 2.5, 4]); +}); + +test("OpenAI long-context rates apply above 272K input tokens per request", () => { + resetCache(); + const short = pricing.priceUsage("openai", "gpt-6-astra", null, U({ in: 200_000, cr: 72_000 })); + const long = pricing.priceUsage("openai", "gpt-6-astra", null, U({ in: 200_000, cr: 72_001 })); + assert.equal(short.parts.in, 2); assert.equal(long.parts.in, 4); +}); + +test("a token class with no published rate makes the call partial, not guessed", () => { + resetCache(); + const r = pricing.priceUsage("openai", "gpt-5.5", null, U({ in: 100_000, c5: 100_000 })); + assert.equal(r.confidence, "partial"); assert.equal(r.parts.in, 0.5); assert.equal(r.parts.cw, 0); + assert.match(r.unknownModel, /gpt-5\.5/); +}); + +// --- The "unknown model is never $0" rule, from the price up to every renderer. +test("an unknown model is unpriced, and renders as n/a, never $0.00", () => { + resetCache(); + const r = pricing.priceUsage("anthropic", "claude-nonexistent-9", "2026-09-29T00:00:00Z", U({ in: 1000, out: 1000 })); + assert.equal(r.confidence, "n/a"); assert.equal(r.unknownModel, "claude-nonexistent-9"); assert.equal(r.price, null); + for (const text of [costLabel(0, "n/a"), ui.costText(0, "n/a"), ui.totalText({ cost: { total: 0, confidence: "n/a" } }), + ui.ownText({ cost: { own: 0, ownConfidence: "n/a" } }), ui.sumText([{ cost: { total: 0, confidence: "n/a" } }])]) { + assert.equal(text, "n/a"); assert.doesNotMatch(text, /\$0\.00/); + } + assert.equal(ui.costText(3.5, "partial"), "≥ $3.50"); + assert.equal(ui.sumText([{ cost: { total: 2, confidence: "computed" } }, { cost: { total: 0, confidence: "n/a" } }]), "≥ $2.00"); +}); + +test("a session on an unknown model builds as n/a, and is not hidden by the min-cost filter", async () => { + resetCache(); + const root = fs.mkdtempSync(path.join(tmp, "claude-")), codexRoot = fs.mkdtempSync(path.join(tmp, "codex-")); + const project = path.join(root, "-fixture"); fs.mkdirSync(project); + const line = (id, model, ts) => JSON.stringify({ type: "assistant", uuid: "u-" + id, timestamp: ts, + message: { id, model, usage: { input_tokens: 1000, output_tokens: 1000, cache_read_input_tokens: 0 }, content: [] } }) + "\n"; + fs.writeFileSync(path.join(project, "aaaaaaaa-0000-4000-8000-000000000001.jsonl"), line("m1", "claude-unreleased-9", "2026-09-29T10:00:00.000Z")); + fs.writeFileSync(path.join(project, "aaaaaaaa-0000-4000-8000-000000000002.jsonl"), + line("m2", "claude-unreleased-9", "2026-09-29T10:00:00.000Z") + line("m3", "claude-opus-5-5", "2026-09-29T10:01:00.000Z")); + process.env.AGENT_ATLAS_CLAUDE_ROOT = root; process.env.AGENT_ATLAS_CODEX_ROOT = codexRoot; + const providers = await import("../src/providers/index.mjs"); + const { toSessionRow, querySessions } = await import("../src/session-index.mjs"); + const refs = providers.discover().sort((a, b) => a.id.localeCompare(b.id)); + const rows = refs.map(ref => toSessionRow(ref, providers.buildTree(ref), Date.now())); + const [unknown, mixed] = rows; + assert.equal(unknown.costConfidence, "n/a"); assert.deepEqual(unknown.unknownModels, ["claude-unreleased-9"]); + assert.equal(mixed.costConfidence, "partial"); assert.equal(mixed.cost, 0.024); // 1000 × $4 + 1000 × $20 per MTok + const tree = providers.buildTree(refs[0]); + assert.equal(tree.calls[0].cost, null); + assert.equal(tree.cost.provenance.length, 0); + assert.equal(providers.buildTree(refs[1]).cost.provenance[0].model, "claude-opus-5-5"); + // min-cost defaults to $0.5: neither session's cost is fully known, so neither is filtered out. + const result = querySessions(rows, { from: "2026-01-01" }); + assert.equal(result.matchedCount, 2); assert.equal(result.summary.unpriced, 2); + // No breakdown group of unpriced sessions may read as a plain $0. + for (const by of ["model", "project", "agent"]) + for (const g of querySessions(rows, { from: "2026-01-01", by }).breakdown) assert.ok(g.value > 0 || g.costConfidence === "n/a", by + ":" + g.key); +}); + +// --- Refresh, cache and fallback. +const PAGE_A = fs.readFileSync(new URL("./fixtures/anthropic-pricing.md", import.meta.url), "utf8"); +const PAGE_O = fs.readFileSync(new URL("./fixtures/openai-pricing.md", import.meta.url), "utf8"); +const serve = pages => async url => { + const body = pages[url.includes("openai") ? "openai" : "anthropic"]; + if (body instanceof Error) throw body; + return { ok: true, status: 200, text: async () => body }; +}; +async function online(fn) { + delete process.env.AGENT_ATLAS_PRICING; + try { return await fn(); } finally { process.env.AGENT_ATLAS_PRICING = "offline"; } +} + +test("the parsers read the official page layouts", () => { + const a = parseAnthropic(PAGE_A), o = parseOpenAI(PAGE_O); + assert.deepEqual(a.find(r => r.model === "claude-opus-5-5").rates, { in: 4, out: 20, cr: 0.2, c5: 5, c1h: 8 }); + assert.deepEqual(a.find(r => r.model === "claude-mythos-5-1").rates, { in: 10, out: 50, cr: 0.25, c5: 12.5, c1h: 20 }); + assert.deepEqual(o.find(r => r.model === "gpt-5.5").long.rates, { in: 10, out: 45, cr: 1, c5: null, c1h: null }); + assert.throws(() => parseAnthropic("# Pricing\n\nredesigned page, no table"), /not found/); + assert.throws(() => parseOpenAI(PAGE_O.replace(/\$10\.00/g, "ten dollars")), /unreadable price cell/); +}); + +test("offline mode never fetches", async () => { + resetCache(); + let calls = 0; + const r = await pricing.refreshPrices({ force: true, fetchImpl: async () => { calls++; throw new Error("no"); } }); + assert.equal(r.offline, true); assert.equal(calls, 0); +}); + +test("a failing or malformed source changes no price and is recorded", async () => { + resetCache(); + const before = pricing.priceUsage("anthropic", "claude-opus-5-5", "2026-10-01T00:00:00Z", U({ in: M })); + const r = await online(() => pricing.refreshPrices({ force: true, + fetchImpl: serve({ anthropic: new Error("ECONNREFUSED"), openai: "not a price page" }) })); + assert.deepEqual(r.sources.map(s => s.status), ["failed", "failed"]); + assert.equal(r.changed, false); + const after = pricing.priceUsage("anthropic", "claude-opus-5-5", "2026-10-01T00:00:00Z", U({ in: M })); + assert.deepEqual(after, before); + const cache = JSON.parse(fs.readFileSync(process.env.AGENT_ATLAS_PRICES_CACHE, "utf8")); + assert.equal(cache.entries.length, 0); assert.match(cache.sources.anthropic.error, /ECONNREFUSED/); + // A failed source is not retried on every scan: the next non-forced refresh waits. + const again = await online(() => pricing.refreshPrices({ fetchImpl: serve({ anthropic: PAGE_A, openai: PAGE_O }) })); + assert.deepEqual(again.sources.map(s => s.status), ["fresh", "fresh"]); +}); + +test("an unchanged page appends nothing; the TTL keeps a fresh source from being re-read", async () => { + resetCache(); + let calls = 0; + const fetchImpl = async url => { calls++; return serve({ anthropic: PAGE_A, openai: PAGE_O })(url); }; + const first = await online(() => pricing.refreshPrices({ force: true, fetchImpl })); + assert.deepEqual(first.sources.map(s => [s.status, s.added]), [["ok", 0], ["ok", 0]]); + await online(() => pricing.refreshPrices({ fetchImpl })); + assert.equal(calls, 2); +}); + +test("a live price change applies from the moment it was observed; history keeps its price", async () => { + resetCache(); + const T = new Date("2026-10-10T12:00:00.000Z"); + const earlier = "2026-10-01T00:00:00.000Z", later = "2026-10-11T00:00:00.000Z"; + const old = pricing.priceUsage("anthropic", "claude-opus-5-5", earlier, U({ in: M })); + const cut = PAGE_A.replace(/\| Claude Opus 5\.5 (\s*)\| \$4 \/ MTok /, "| Claude Opus 5.5 $1| $3 / MTok "); + assert.notEqual(cut, PAGE_A); + const r = await online(() => pricing.refreshPrices({ force: true, now: () => T, fetchImpl: serve({ anthropic: cut, openai: PAGE_O }) })); + assert.equal(r.changed, true); + assert.deepEqual(pricing.priceUsage("anthropic", "claude-opus-5-5", earlier, U({ in: M })), old); + const now = pricing.priceUsage("anthropic", "claude-opus-5-5", later, U({ in: M })); + assert.equal(now.usd, 3); + const p = pricing.provenance(now.price); + assert.equal(p.source.kind, "live"); assert.equal(p.from, T.toISOString()); + // A later failure leaves both answers exactly where they were. + await online(() => pricing.refreshPrices({ force: true, fetchImpl: serve({ anthropic: new Error("down"), openai: new Error("down") }) })); + assert.deepEqual(pricing.priceUsage("anthropic", "claude-opus-5-5", earlier, U({ in: M })), old); + assert.equal(pricing.priceUsage("anthropic", "claude-opus-5-5", later, U({ in: M })).usd, 3); +}); + +test("a model first seen live is priced back to its first use", async () => { + resetCache(); + const page = PAGE_A.replace("| Claude Haiku 4.5 ", "| Claude Haiku 9 | $7 / MTok | $1 / MTok | $1 / MTok | $1 / MTok | $9 / MTok |\n| Claude Haiku 4.5 "); + await online(() => pricing.refreshPrices({ force: true, fetchImpl: serve({ anthropic: page, openai: PAGE_O }) })); + const r = pricing.priceUsage("anthropic", "claude-haiku-9", "2020-01-01T00:00:00Z", U({ in: M })); + assert.equal(r.usd, 7); assert.equal(pricing.provenance(r.price).from, null); + resetCache(); + assert.equal(pricing.priceUsage("anthropic", "claude-haiku-9", null, U({ in: M })).confidence, "n/a"); +}); diff --git a/test/server.test.mjs b/test/server.test.mjs index aaca9e8..055b8aa 100644 --- a/test/server.test.mjs +++ b/test/server.test.mjs @@ -6,6 +6,9 @@ import path from "node:path"; import { once } from "node:events"; import { serve } from "../src/server.mjs"; +// The index worker refreshes prices in the background; a test must not touch the network. +process.env.AGENT_ATLAS_PRICING = "offline"; + test("an empty corpus changes from indexing to complete without a row revision", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "agent-atlas-empty-")); const codexRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agent-atlas-empty-codex-")); const previous = process.env.AGENT_ATLAS_CLAUDE_ROOT, previousCodex = process.env.AGENT_ATLAS_CODEX_ROOT;