diff --git a/app/src/components/shell/sidebar.tsx b/app/src/components/shell/sidebar.tsx index 0205e0a6d..6b36b105e 100644 --- a/app/src/components/shell/sidebar.tsx +++ b/app/src/components/shell/sidebar.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { LayoutDashboard, Blend, Settings } from "lucide-react"; +import { LayoutDashboard, Blend, Settings, BarChart2 } from "lucide-react"; import { ConfirmDialog } from "@houston-ai/core"; import { AppSidebar, WorkspaceSwitcher } from "@houston-ai/layout"; import { useWorkspaceStore } from "../../stores/workspaces"; @@ -50,7 +50,7 @@ export function Sidebar({ children }: { children: ReactNode }) { onShareAgent: (agentId) => useUIStore.getState().setShareAgentId(agentId), shareLabel: t("portable:shareMenu"), }); - const isTopLevel = viewMode === "dashboard" || viewMode === "connections" || viewMode === "settings"; + const isTopLevel = viewMode === "dashboard" || viewMode === "connections" || viewMode === "settings" || viewMode === "usage"; const handleWorkspaceSwitch = async (wsId: string) => { if (wsId === currentWorkspace?.id) return; @@ -135,6 +135,12 @@ export function Sidebar({ children }: { children: ReactNode }) { icon: , onClick: () => setViewMode("settings"), }, + { + id: "usage", + label: t("shell:sidebar.usage"), + icon: , + onClick: () => setViewMode("usage"), + }, ]} activeNavId={isTopLevel ? viewMode : undefined} sectionLabel={t("shell:sidebar.yourAgents")} diff --git a/app/src/components/shell/workspace-shell.tsx b/app/src/components/shell/workspace-shell.tsx index 8cc884ea2..9234da030 100644 --- a/app/src/components/shell/workspace-shell.tsx +++ b/app/src/components/shell/workspace-shell.tsx @@ -25,6 +25,7 @@ import { AgentRenderer } from "./experience-renderer"; import { Dashboard } from "../dashboard"; import { IntegrationsView } from "../tabs/integrations-view"; import { SettingsView } from "../settings/settings-view"; +import { UsageView } from "../usage-view"; import { Sidebar } from "./sidebar"; import { HoustonLogo } from "./experience-card"; import { CreateAgentDialog } from "./create-workspace-dialog"; @@ -70,7 +71,7 @@ export function WorkspaceShell({ toasts, onDismissToast }: WorkspaceShellProps) const { data: activities } = useActivity(currentAgent?.folderPath); const needsYouCount = (activities ?? []).filter((a) => a.status === "needs_you").length; const isAgentView = - viewMode !== "dashboard" && viewMode !== "connections" && viewMode !== "settings"; + viewMode !== "dashboard" && viewMode !== "connections" && viewMode !== "settings" && viewMode !== "usage"; const tabOr = (id: string) => (STANDARD_TAB_IDS.has(id) ? id : DEFAULT_TAB_ID); useEffect(() => { @@ -122,6 +123,8 @@ export function WorkspaceShell({ toasts, onDismissToast }: WorkspaceShellProps) ) : viewMode === "settings" ? ( + ) : viewMode === "usage" ? ( + ) : currentAgent && agentDef && isAgentView ? ( <>
diff --git a/app/src/components/usage-chart.tsx b/app/src/components/usage-chart.tsx new file mode 100644 index 000000000..da4d4a01b --- /dev/null +++ b/app/src/components/usage-chart.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState } from "react"; + +// SVG layout constants +const VW = 480; +const VH = 160; +const PL = 42; // left padding for Y labels +const PR = 8; +const PT = 12; +const PB = 28; +const CW = VW - PL - PR; +const CH = VH - PT - PB; + +function fmtTick(v: number): string { + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (v >= 1_000) return `${(v / 1_000).toFixed(0)}K`; + if (v >= 1 && v < 1000) return v.toFixed(v < 10 ? 2 : 0); + if (v > 0 && v < 1) return v.toFixed(4); + return String(Math.round(v)); +} + +function niceMax(raw: number): number { + if (raw === 0) return 1; + const mag = Math.pow(10, Math.floor(Math.log10(raw))); + const norm = raw / mag; + const nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10; + return nice * mag; +} + +function mk(tag: string, attrs: Record): SVGElement { + const el = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)); + return el; +} + +export interface BarChartProps { + values: number[]; + xLabels: (string | null)[]; + tooltips: string[]; + caption?: string; +} + +interface ChartColors { + fg: string; + muted: string; + bg: string; +} + +/** Read a computed color off a throwaway element carrying a Tailwind class, so + * we get the real themed token value rather than guessing a CSS variable name. */ +function probeColor(className: string, prop: "color" | "backgroundColor"): string { + const tmp = document.createElement("span"); + tmp.className = className; + tmp.style.cssText = "position:fixed;opacity:0;pointer-events:none"; + document.body.appendChild(tmp); + const value = getComputedStyle(tmp)[prop]; + document.body.removeChild(tmp); + return value; +} + +function readChartColors(container: HTMLElement | null): ChartColors { + return { + fg: getComputedStyle(container ?? document.body).color || "#000", + muted: probeColor("text-muted-foreground", "color") || "#888", + bg: probeColor("bg-background", "backgroundColor") || "#fff", + }; +} + +export function BarChart({ values, xLabels, tooltips, caption }: BarChartProps) { + const svgRef = useRef(null); + const containerRef = useRef(null); + const [hover, setHover] = useState(null); + const [colors, setColors] = useState({ fg: "#000", muted: "#888", bg: "#fff" }); + + // Resolve themed colors on mount, and re-resolve when the theme toggles + // (the app flips data-theme on ). Keeping colors in state means the + // draw effect below redraws automatically on a live light/dark switch. + useEffect(() => { + setColors(readChartColors(containerRef.current)); + const observer = new MutationObserver(() => { + setColors(readChartColors(containerRef.current)); + }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const svg = svgRef.current; + // Guard: svg may be removed from DOM during unmount before this effect runs + if (!svg || !svg.isConnected) return; + while (svg.firstChild) svg.removeChild(svg.firstChild); + + const n = values.length; + if (n === 0) return; + + const fgColor = colors.fg; + const mutedColor = colors.muted; + const rawMax = Math.max(...values, 0); + const ticks = (() => { + const nice = niceMax(rawMax); + const step = nice / 4; + return Array.from({ length: 5 }, (_, i) => i * step); + })(); + const maxTick = ticks[ticks.length - 1]; + const toY = (v: number) => PT + (1 - v / maxTick) * CH; + const gap = 2; + const barW = Math.max(2, CW / n - gap); + const toX = (i: number) => PL + (i / n) * CW + (CW / n - barW) / 2; + + // Y gridlines + labels + for (const tick of ticks) { + const y = toY(tick); + svg.appendChild(mk("line", { + x1: PL, y1: y, x2: PL + CW, y2: y, + stroke: fgColor, + "stroke-opacity": tick === 0 ? "0.12" : "0.06", + "stroke-width": "1", + "stroke-dasharray": tick === 0 ? "" : "3 3", + })); + const label = mk("text", { + x: PL - 5, y, "text-anchor": "end", "dominant-baseline": "middle", + "font-size": "9", fill: mutedColor, + }); + label.textContent = fmtTick(tick); + svg.appendChild(label); + } + + // Bars + for (let i = 0; i < n; i++) { + const bx = toX(i); + const barH = Math.max(0, (values[i] / maxTick) * CH); + const by = toY(values[i]); + const isHovered = hover === i; + svg.appendChild(mk("rect", { + x: bx, y: by, width: barW, height: barH, + rx: Math.min(3, barW / 4), + fill: fgColor, + "fill-opacity": isHovered ? "0.85" : values[i] > 0 ? "0.5" : "0.1", + })); + } + + // X labels + for (let i = 0; i < n; i++) { + const lbl = xLabels[i]; + if (!lbl) continue; + const cx = toX(i) + barW / 2; + const t = mk("text", { + x: cx, y: PT + CH + 14, "text-anchor": "middle", + "font-size": "9", fill: mutedColor, + }); + t.textContent = lbl; + svg.appendChild(t); + } + + // Hover tooltip + if (hover !== null) { + const bx = toX(hover); + const cx = bx + barW / 2; + const hy = toY(values[hover]); + const tip = tooltips[hover]; + const bw = Math.max(tip.length * 5.6 + 16, 60); + const bh = 20; + const tx = Math.min(Math.max(cx - bw / 2, PL), PL + CW - bw); + const ty = Math.max(hy - bh - 8, PT); + svg.appendChild(mk("rect", { + x: tx, y: ty, width: bw, height: bh, rx: "4", + fill: fgColor, "fill-opacity": "0.9", + })); + const tipText = mk("text", { + x: tx + bw / 2, y: ty + bh / 2, + "text-anchor": "middle", "dominant-baseline": "middle", + "font-size": "9.5", fill: colors.bg, + }); + tipText.textContent = tip; + svg.appendChild(tipText); + } + // Return cleanup: clear SVG children safely on unmount + return () => { + if (svg.isConnected) { + while (svg.firstChild) svg.removeChild(svg.firstChild); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values, xLabels, tooltips, hover, colors]); + + return ( +
+ setHover(null)} + onMouseMove={(e) => { + const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect(); + const svgX = ((e.clientX - rect.left) / rect.width) * VW; + const plotX = svgX - PL; + const idx = Math.floor((plotX / CW) * values.length); + const clamped = Math.max(0, Math.min(values.length - 1, idx)); + setHover(plotX >= 0 && plotX <= CW ? clamped : null); + }} + /> + {caption &&

{caption}

} +
+ ); +} diff --git a/app/src/components/usage-parts.tsx b/app/src/components/usage-parts.tsx new file mode 100644 index 000000000..a8588bb64 --- /dev/null +++ b/app/src/components/usage-parts.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; + +export { fmtTokens, fmtCost, shortModel } from "../lib/usage-format"; + +export function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function KpiCard({ icon, label, value }: { icon: ReactNode; label: string; value: string }) { + return ( +
+
+ {icon} + {label} +
+

{value}

+
+ ); +} + +export function DataBar({ pct }: { pct: number }) { + return ( +
+
+
+ ); +} diff --git a/app/src/components/usage-view.tsx b/app/src/components/usage-view.tsx new file mode 100644 index 000000000..c9be2a0d2 --- /dev/null +++ b/app/src/components/usage-view.tsx @@ -0,0 +1,202 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BarChart2, Zap, Layers, DollarSign, Clock, Cpu, ChevronDown, Info } from "lucide-react"; +import { cn } from "@houston-ai/core"; +import { useCostAnalytics, applyFilter } from "../hooks/use-cost-analytics"; +import { aggregate, cacheHitPct } from "../lib/cost-aggregate"; +import { BarChart } from "./usage-chart"; +import { KpiCard, Section, DataBar, fmtTokens, fmtCost, shortModel } from "./usage-parts"; + +type FilterKind = "all" | "agent" | "model"; +interface Filter { kind: FilterKind; value: string; label: string } +/** Selection identity, without a frozen label. The label is derived from the + * current options so it can't go stale on a language change or agent rename. */ +type Selection = Pick; + +export function UsageView() { + const { t } = useTranslation("shell"); + const data = useCostAnalytics(); + const [selection, setSelection] = useState({ kind: "all", value: "" }); + const [dropdownOpen, setDropdownOpen] = useState(false); + + const filterOptions = useMemo(() => { + const opts: Filter[] = [{ kind: "all", value: "", label: t("usage.filterAll") }]; + for (const a of data.agents) opts.push({ kind: "agent", value: a.path, label: a.name }); + for (const m of data.models) opts.push({ kind: "model", value: m, label: shortModel(m) }); + return opts; + }, [data.agents, data.models, t]); + + // A selected agent/model can disappear (deleted, or it had no sessions); fall + // back to "all" so the view never shows a dangling filter. + const filter = filterOptions.find((o) => o.kind === selection.kind && o.value === selection.value) + ?? filterOptions[0]; + + const metrics = useMemo( + () => filter.kind === "all" ? data : aggregate(applyFilter(data.sessions, filter.kind, filter.value)), + [data, filter], + ); + + if (data.loading) { + return ( +
+
+ +

{t("usage.loading")}

+
+
+ ); + } + + if (data.totalSessions === 0) { + return ( +
+
+ +

{t("usage.emptyTitle")}

+

{t("usage.emptySubtitle")}

+
+
+ ); + } + + const recentDays = metrics.byDay.slice(-14); + const useTokensForDay = !metrics.hasCostData; + const maxAgentTokens = Math.max(...metrics.byAgent.map((a) => a.totalTokens), 1); + const maxModelTokens = Math.max(...metrics.byModel.map((m) => m.totalTokens), 1); + + return ( +
+ {dropdownOpen &&
setDropdownOpen(false)} />} +
+ +
+
+

{t("usage.title")}

+

{t("usage.subtitle")}

+
+ {filterOptions.length > 1 && ( +
+ + {dropdownOpen && ( +
+ {filterOptions.map((opt) => ( + + ))} +
+ )} +
+ )} +
+ +
+ {metrics.hasCostData + ? } label={t("usage.totalSpend")} value={fmtCost(metrics.totalCost)} /> + : } label={t("usage.totalTokens")} value={fmtTokens(metrics.totalTokens)} />} + } label={t("usage.totalSessions")} value={String(metrics.totalSessions)} /> + } label={t("usage.cacheEfficiency")} value={`${metrics.cacheEfficiencyPct}%`} /> + {metrics.hasCostData + ? } label={t("usage.avgCostPerSession")} value={metrics.totalSessions > 0 ? fmtCost(metrics.totalCost / metrics.totalSessions) : "$0.00"} /> + : } label={t("usage.cachedTokens")} value={fmtTokens(metrics.cachedTokens)} />} +
+ + {metrics.hasCostData && ( +
+ } label={t("usage.totalTokens")} value={fmtTokens(metrics.totalTokens)} /> + } label={t("usage.cachedTokens")} value={`${fmtTokens(metrics.cachedTokens)} (${metrics.cacheEfficiencyPct}%)`} /> +
+ )} + +
+ + {t("usage.dataNote")} +
+ + {metrics.byModel.length > 0 && ( +
+
+ {metrics.byModel.map((m) => ( +
+
+ {m.model} +
+ {t("usage.sessions", { count: m.sessionCount })} + {fmtTokens(m.totalTokens)} {t("usage.tokens")} + {m.hasCost && m.totalCost > 0 && {fmtCost(m.totalCost)}} +
+
+ +
+ ))} +
+
+ )} + + {metrics.byAgent.length > 1 && ( +
+
+ {metrics.byAgent.map((agent) => ( +
+
+ {agent.agentName} +
+ {t("usage.sessions", { count: agent.sessionCount })} + {fmtTokens(agent.totalTokens)} {t("usage.tokens")} + {agent.hasCost && agent.totalCost > 0 && {fmtCost(agent.totalCost)}} +
+
+ + {agent.contextTokens > 0 && ( +

{t("usage.cacheHit", { pct: cacheHitPct(agent.cachedTokens, agent.contextTokens) })}

+ )} +
+ ))} +
+
+ )} + + {recentDays.length > 1 && ( +
+ useTokensForDay ? d.tokens : d.cost)} + xLabels={recentDays.map((d, i) => i === 0 || i === recentDays.length - 1 || i % 3 === 0 ? d.date.slice(5) : null)} + tooltips={recentDays.map((d) => useTokensForDay ? `${d.date.slice(5)}: ${fmtTokens(d.tokens)} ${t("usage.tokens")}` : `${d.date.slice(5)}: ${fmtCost(d.cost)}`)} + caption={useTokensForDay ? t("usage.captionByDay") : t("usage.captionByDayCost")} + /> +
+ )} + + {metrics.byHour.some((h) => h.tokens > 0) && ( +
+ h.tokens)} + xLabels={metrics.byHour.map((h) => h.hour % 6 === 0 ? `${String(h.hour).padStart(2, "0")}h` : null)} + tooltips={metrics.byHour.map((h) => { const l = `${String(h.hour).padStart(2, "0")}h`; return h.tokens > 0 ? `${l}: ${fmtTokens(h.tokens)} (${h.sessions}s)` : l; })} + caption={t("usage.captionByHour")} + /> +
+ )} + +
+
+ ); +} diff --git a/app/src/hooks/use-agent-invalidation.ts b/app/src/hooks/use-agent-invalidation.ts index d02ec5045..3f60209c4 100644 --- a/app/src/hooks/use-agent-invalidation.ts +++ b/app/src/hooks/use-agent-invalidation.ts @@ -22,6 +22,7 @@ export function useAgentInvalidation() { useSessionStatusStore.getState().clearAll(); qc.invalidateQueries({ queryKey: ["activity"] }); qc.invalidateQueries({ queryKey: ["all-conversations"] }); + qc.invalidateQueries({ queryKey: ["cost-analytics"] }); }); const unlisten = subscribeHoustonEvents((p: HoustonEvent) => { console.log("[invalidation] event:", p.type, "data" in p ? (p as { data: { agent_path?: string } }).data?.agent_path : ""); @@ -56,11 +57,13 @@ export function useAgentInvalidation() { case "LearningsChanged": qc.invalidateQueries({ queryKey: queryKeys.learnings(p.data.agent_path) }); break; - // SessionStatus triggers activity invalidation (agent finished → status changed) + // SessionStatus triggers activity invalidation (agent finished → status changed). + // A finished session also wrote a new final_result, so refresh cost analytics. case "SessionStatus": if (p.data.status === "completed" || p.data.status === "error") { qc.invalidateQueries({ queryKey: ["activity"] }); qc.invalidateQueries({ queryKey: ["all-conversations"] }); + qc.invalidateQueries({ queryKey: ["cost-analytics"] }); } break; // Composio CLI became available — refresh integrations state. diff --git a/app/src/hooks/use-cost-analytics.ts b/app/src/hooks/use-cost-analytics.ts new file mode 100644 index 000000000..b65efec68 --- /dev/null +++ b/app/src/hooks/use-cost-analytics.ts @@ -0,0 +1,97 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAgentStore } from "../stores/agents"; +import { queryKeys } from "../lib/query-keys"; +import { tauriConversations } from "../lib/tauri"; +import { getEngine } from "../lib/engine"; +import { isoToLocalDate } from "../lib/date-utils"; +import { calcTokenCost } from "../lib/token-pricing"; +import { aggregate, foldFinals, type AggregatedMetrics, type SessionResult, type FinalResultRow } from "../lib/cost-aggregate"; + +export type { SessionResult, AggregatedMetrics, AgentMetrics, ModelMetrics, DailyMetrics, HourlyMetrics } from "../lib/cost-aggregate"; +export { applyFilter } from "../lib/cost-aggregate"; + +export interface CostAnalytics extends AggregatedMetrics { + sessions: SessionResult[]; + models: string[]; + agents: { name: string; path: string }[]; + loading: boolean; +} + +const CONCURRENCY = 6; + +async function limitedAll(tasks: (() => Promise)[], limit: number): Promise { + const results: T[] = []; + for (let i = 0; i < tasks.length; i += limit) { + results.push(...await Promise.all(tasks.slice(i, i + limit).map((fn) => fn()))); + } + return results; +} + +/** + * Read every conversation's final-result rows and fold them into one + * SessionResult per conversation. The top-level conversation list goes through + * the toast-on-error tauri wrapper; individual history reads use the engine + * directly and skip on failure so one unreadable conversation can't blank the + * whole dashboard. + */ +async function loadSessions(agentPaths: string[]): Promise { + const conversations = await tauriConversations.listAll(agentPaths); + const engine = getEngine(); + const sessions: SessionResult[] = []; + + await limitedAll( + conversations.map((conv) => async () => { + try { + const feed = await engine.loadChatHistory(conv.agent_path, conv.session_key); + const finals = feed + .filter((f) => f.feed_type === "final_result") + .map((f) => f.data as FinalResultRow); + if (!finals.length) return; + + sessions.push({ + agentName: conv.agent_name, + agentPath: conv.agent_path, + date: conv.updated_at ? isoToLocalDate(conv.updated_at) : "", + hour: conv.updated_at ? new Date(conv.updated_at).getHours() : -1, + ...foldFinals(finals, calcTokenCost), + }); + } catch { + // Skip a single unreadable conversation; the rest still aggregate. + } + }), + CONCURRENCY, + ); + + return sessions; +} + +export function useCostAnalytics(): CostAnalytics { + const agents = useAgentStore((s) => s.agents); + const agentPaths = agents.map((a) => a.folderPath); + + const query = useQuery({ + queryKey: queryKeys.costAnalytics(agentPaths), + queryFn: () => loadSessions(agentPaths), + enabled: agents.length > 0, + }); + + const sessions = useMemo(() => query.data ?? [], [query.data]); + const metrics = useMemo(() => aggregate(sessions), [sessions]); + const models = useMemo( + () => [...new Set(sessions.map((s) => s.model).filter(Boolean))], + [sessions], + ); + const agentList = useMemo( + () => agents.map((a) => ({ name: a.name, path: a.folderPath })), + [agents], + ); + + return { + ...metrics, + sessions, + models, + agents: agentList, + loading: agents.length > 0 && query.isPending, + }; +} diff --git a/app/src/lib/cost-aggregate.ts b/app/src/lib/cost-aggregate.ts new file mode 100644 index 000000000..43526082b --- /dev/null +++ b/app/src/lib/cost-aggregate.ts @@ -0,0 +1,222 @@ +/** A persisted `final_result` feed row's data payload (one provider turn). */ +export interface FinalResultRow { + cost_usd?: number | null; + model?: string | null; + usage?: { context_tokens: number; output_tokens: number; cached_tokens: number } | null; +} + +/** Cost and token totals folded from one conversation's final-result turns. */ +export interface FoldedUsage { + model: string; + cost: number; + hasCost: boolean; + totalTokens: number; + contextTokens: number; + cachedTokens: number; +} + +/** Estimate a turn's cost from its model and token counts, or null if unpriced. */ +export type CostEstimator = ( + model: string, + contextTokens: number, + outputTokens: number, + cachedTokens: number, +) => number | null; + +/** + * Fold a conversation's final-result turns into a single usage record. + * + * Per turn: use the CLI's `cost_usd` when present (exact), otherwise estimate + * from the model + token usage via `estimate`. Tokens are summed across turns + * (each turn is a separately billed request). `model` is the last turn's model, + * used to label the conversation in the by-model breakdown. + * + * The estimator is injected so this stays free of pricing-table imports. + */ +export function foldFinals(finals: FinalResultRow[], estimate: CostEstimator): FoldedUsage { + let model = "", cost = 0, hasCost = false; + let totalTokens = 0, contextTokens = 0, cachedTokens = 0; + + for (const d of finals) { + if (d.model) model = d.model; + if (d.cost_usd != null) { + cost += d.cost_usd; + hasCost = true; + } else if (d.model && d.usage) { + const estimated = estimate(d.model, d.usage.context_tokens, d.usage.output_tokens, d.usage.cached_tokens); + if (estimated != null) { + cost += estimated; + hasCost = true; + } + } + if (d.usage) { + totalTokens += d.usage.context_tokens + d.usage.output_tokens; + contextTokens += d.usage.context_tokens; + cachedTokens += d.usage.cached_tokens; + } + } + + return { model, cost, hasCost, totalTokens, contextTokens, cachedTokens }; +} + +export interface SessionResult { + agentName: string; + agentPath: string; + model: string; + cost: number; + hasCost: boolean; + /** All tokens billed this session (input + output). */ + totalTokens: number; + /** Input (context) tokens only, the denominator for cache hit rate. */ + contextTokens: number; + /** Cache-read tokens (a subset of contextTokens). */ + cachedTokens: number; + date: string; + hour: number; +} + +export interface AgentMetrics { + agentName: string; + agentPath: string; + totalCost: number; + hasCost: boolean; + sessionCount: number; + totalTokens: number; + contextTokens: number; + cachedTokens: number; +} + +export interface ModelMetrics { + model: string; + sessionCount: number; + totalTokens: number; + contextTokens: number; + cachedTokens: number; + totalCost: number; + hasCost: boolean; +} + +/** Cache hit rate as a 0-100 percent: cache reads over total input tokens. */ +export function cacheHitPct(cachedTokens: number, contextTokens: number): number { + return contextTokens > 0 ? Math.round((cachedTokens / contextTokens) * 100) : 0; +} + +export interface DailyMetrics { + date: string; + cost: number; + tokens: number; +} + +export interface HourlyMetrics { + hour: number; + tokens: number; + sessions: number; +} + +export interface AggregatedMetrics { + totalCost: number; + totalSessions: number; + totalTokens: number; + contextTokens: number; + cachedTokens: number; + cacheEfficiencyPct: number; + hasCostData: boolean; + byAgent: AgentMetrics[]; + byModel: ModelMetrics[]; + byDay: DailyMetrics[]; + byHour: HourlyMetrics[]; +} + +export function aggregate(sessions: SessionResult[]): AggregatedMetrics { + const agentMap = new Map(); + const modelMap = new Map(); + const dayMap = new Map(); + const hourMap = new Map(); + let totalCost = 0, totalTokens = 0, contextTokens = 0, cachedTokens = 0; + let hasCostData = false; + + for (const s of sessions) { + totalCost += s.cost; + totalTokens += s.totalTokens; + contextTokens += s.contextTokens; + cachedTokens += s.cachedTokens; + if (s.hasCost) hasCostData = true; + + const ag = agentMap.get(s.agentPath); + if (ag) { + ag.totalCost += s.cost; + ag.sessionCount += 1; + ag.totalTokens += s.totalTokens; + ag.contextTokens += s.contextTokens; + ag.cachedTokens += s.cachedTokens; + if (s.hasCost) ag.hasCost = true; + } else { + agentMap.set(s.agentPath, { + agentName: s.agentName, agentPath: s.agentPath, + totalCost: s.cost, hasCost: s.hasCost, sessionCount: 1, + totalTokens: s.totalTokens, contextTokens: s.contextTokens, cachedTokens: s.cachedTokens, + }); + } + + if (s.model) { + const md = modelMap.get(s.model); + if (md) { + md.sessionCount += 1; + md.totalTokens += s.totalTokens; + md.contextTokens += s.contextTokens; + md.cachedTokens += s.cachedTokens; + md.totalCost += s.cost; + if (s.hasCost) md.hasCost = true; + } else { + modelMap.set(s.model, { + model: s.model, sessionCount: 1, totalTokens: s.totalTokens, + contextTokens: s.contextTokens, cachedTokens: s.cachedTokens, + totalCost: s.cost, hasCost: s.hasCost, + }); + } + } + + if (s.date) { + const d = dayMap.get(s.date) ?? { cost: 0, tokens: 0 }; + d.cost += s.cost; + d.tokens += s.totalTokens; + dayMap.set(s.date, d); + } + + if (s.hour >= 0) { + const h = hourMap.get(s.hour) ?? { tokens: 0, sessions: 0 }; + h.tokens += s.totalTokens; + h.sessions += 1; + hourMap.set(s.hour, h); + } + } + + const byDay = [...dayMap.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, v]) => ({ date, ...v })); + + const byHour = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + tokens: hourMap.get(i)?.tokens ?? 0, + sessions: hourMap.get(i)?.sessions ?? 0, + })); + + const byAgent = [...agentMap.values()].sort((a, b) => b.totalTokens - a.totalTokens); + const byModel = [...modelMap.values()].sort((a, b) => b.totalTokens - a.totalTokens); + + return { + totalCost, totalSessions: sessions.length, totalTokens, contextTokens, cachedTokens, + cacheEfficiencyPct: cacheHitPct(cachedTokens, contextTokens), + hasCostData, byAgent, byModel, byDay, byHour, + }; +} + +export function applyFilter( + sessions: SessionResult[], + kind: "all" | "agent" | "model", + value: string, +): SessionResult[] { + if (kind === "all") return sessions; + if (kind === "agent") return sessions.filter((s) => s.agentPath === value); + return sessions.filter((s) => s.model === value); +} diff --git a/app/src/lib/date-utils.ts b/app/src/lib/date-utils.ts new file mode 100644 index 000000000..60a49b25b --- /dev/null +++ b/app/src/lib/date-utils.ts @@ -0,0 +1,5 @@ +/** Convert a UTC ISO string to a local calendar date "YYYY-MM-DD". */ +export function isoToLocalDate(iso: string): string { + const d = new Date(iso); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} diff --git a/app/src/lib/query-keys.ts b/app/src/lib/query-keys.ts index 5d8e3f3ca..47b301fd2 100644 --- a/app/src/lib/query-keys.ts +++ b/app/src/lib/query-keys.ts @@ -25,6 +25,8 @@ export const queryKeys = { ["conversations", agentPath] as const, allConversations: (agentPaths: string[]) => ["all-conversations", ...agentPaths] as const, + costAnalytics: (agentPaths: string[]) => + ["cost-analytics", ...agentPaths] as const, chatHistory: (agentPath: string, sessionKey: string) => ["chat-history", agentPath, sessionKey] as const, diff --git a/app/src/lib/token-pricing.ts b/app/src/lib/token-pricing.ts new file mode 100644 index 000000000..cedcf94e4 --- /dev/null +++ b/app/src/lib/token-pricing.ts @@ -0,0 +1,81 @@ +/** + * Token pricing used to estimate session cost in USD. + * + * When the provider CLI reports an exact `cost_usd` (Claude on API-key billing), + * that value is used directly and this table is never consulted. When it does + * not (Claude on a subscription, or Codex, which never report cost), cost is + * estimated here from token usage at each provider's published API rates. For + * subscription users this is an API-equivalent estimate of value, not a literal + * charge. + * + * Only models Houston's picker can select are priced (see `providers.ts`). To + * support a new model, add it to the picker AND add its row here. + * + * Prices in USD per million tokens. Sources (verified June 2026): + * Anthropic: platform.claude.com/docs/en/docs/about-claude/pricing + * OpenAI: developers.openai.com/api/docs/pricing + * + * Cache-write caveat: the persisted TokenUsage folds cache-creation (write) + * tokens into `context_tokens` without separating them, so this estimate prices + * them at the base input rate rather than the 1.25x cache-write rate. The + * resulting estimate is therefore a slight under-count on cache-heavy turns. + * It is acceptable for an estimate, and exact figures come from `cost_usd` when + * the CLI provides them. + */ +interface ModelPricing { + /** Cost per million fresh input tokens. */ + input: number; + /** Cost per million output tokens. */ + output: number; + /** Cost per million cache-read tokens. */ + cacheRead: number; +} + +const PRICING: Record = { + // Anthropic + "claude-opus-4-8": { input: 5.0, output: 25.0, cacheRead: 0.5 }, + "claude-opus-4-7": { input: 5.0, output: 25.0, cacheRead: 0.5 }, + "claude-sonnet-4-6": { input: 3.0, output: 15.0, cacheRead: 0.3 }, + // OpenAI (Codex) + "gpt-5.5": { input: 5.0, output: 30.0, cacheRead: 0.5 }, +}; + +/** + * Legacy shorthand model ids that older agent configs may still hold, mapped to + * the catalog id they denote. Mirrors `LEGACY_MODEL_ALIASES` in providers.ts. + */ +const MODEL_ALIASES: Record = { + opus: "claude-opus-4-7", + sonnet: "claude-sonnet-4-6", +}; + +function getPricing(model: string): ModelPricing | null { + const resolved = MODEL_ALIASES[model] ?? model; + if (PRICING[resolved]) return PRICING[resolved]; + // Prefix fall-back so a date- or variant-suffixed id still resolves to its + // base model's rate (e.g. "claude-sonnet-4-6-20260101", "gpt-5.5-2026..."). + for (const [key, price] of Object.entries(PRICING)) { + if (resolved.startsWith(key)) return price; + } + return null; +} + +/** + * Estimate cost in USD from token counts. + * Returns null when the model is unpriced, so the caller shows token usage + * instead of a cost. + * + * `cachedTokens` are billed at the cheaper cacheRead rate; the remaining + * `contextTokens - cachedTokens` are billed at the full input rate. + */ +export function calcTokenCost( + model: string, + contextTokens: number, + outputTokens: number, + cachedTokens: number, +): number | null { + const p = getPricing(model); + if (!p) return null; + const fresh = Math.max(0, contextTokens - cachedTokens); + return (fresh * p.input + cachedTokens * p.cacheRead + outputTokens * p.output) / 1_000_000; +} diff --git a/app/src/lib/usage-format.ts b/app/src/lib/usage-format.ts new file mode 100644 index 000000000..defb41666 --- /dev/null +++ b/app/src/lib/usage-format.ts @@ -0,0 +1,33 @@ +export function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +export function fmtCost(n: number): string { + if (n === 0) return "$0.00"; + if (n >= 1) return `$${n.toFixed(2)}`; + if (n >= 0.01) return `$${n.toFixed(4)}`; + return `$${n.toFixed(6)}`; +} + +/** + * Human-readable model label for the filter dropdown. + * + * Handles the ids Houston's picker produces (providers.ts): Claude full ids, + * the OpenAI `gpt-5.5` id, and the legacy `sonnet`/`opus` shorthands that older + * configs may still hold. Anything else is title-cased as a safe fallback. + */ +export function shortModel(model: string): string { + // Claude full id: "claude-sonnet-4-6" → "Sonnet 4.6" + const claude = model.match(/^claude-([a-z]+)-(\d[\d-]*)$/i); + if (claude) { + const name = claude[1].charAt(0).toUpperCase() + claude[1].slice(1); + return `${name} ${claude[2].replace(/-/g, ".")}`; + } + // OpenAI version id: "gpt-5.5" → "GPT-5.5" + const gpt = model.match(/^gpt-(\d[\d.]+)$/i); + if (gpt) return `GPT-${gpt[1]}`; + // Legacy shorthand ("sonnet", "opus"): capitalise first letter. + return model.charAt(0).toUpperCase() + model.slice(1); +} diff --git a/app/src/locales/en/shell.json b/app/src/locales/en/shell.json index a8cdb466c..f89044e81 100644 --- a/app/src/locales/en/shell.json +++ b/app/src/locales/en/shell.json @@ -21,7 +21,8 @@ "needsYouCount_one": "{{count}} issue needs you", "needsYouCount_other": "{{count}} issues need you", "runningCount_one": "{{count}} issue running", - "runningCount_other": "{{count}} issues running" + "runningCount_other": "{{count}} issues running", + "usage": "Usage" }, "tabActions": { "newMission": "New mission", @@ -396,5 +397,34 @@ "successWithId": "Mission control is on it. Reference: {{id}}", "successNoId": "Mission control is on it.", "errorTitle": "Couldn't send report" + }, + "usage": { + "title": "Usage", + "subtitle": "Cost and token consumption across your agents", + "loading": "Loading usage data...", + "emptyTitle": "No sessions yet", + "emptySubtitle": "Start a conversation with any agent to see usage here.", + "totalSpend": "Total spend", + "totalSessions": "Sessions", + "totalTokens": "Tokens", + "cachedTokens": "Cached", + "cacheEfficiency": "Cache hit", + "avgCostPerSession": "Avg / session", + "filterAll": "All agents", + "filterAgent": "Agent", + "filterModel": "Model", + "byAgent": "By agent", + "byModel": "By model", + "byHour": "Activity by hour", + "dailyTrend": "Daily trend", + "tokens": "tokens", + "cacheHit": "{{pct}}% cache hit", + "captionByDay": "Tokens per day (last 14 days)", + "captionByDayCost": "Cost per day (last 14 days)", + "captionByHour": "Tokens by local hour", + "dataNote": "Cost is exact when Claude reports it, otherwise estimated from official published pricing. Gemini shows tokens only.", + "loadError": "Failed to load usage data", + "sessions_one": "session", + "sessions_other": "sessions" } } diff --git a/app/src/locales/es/shell.json b/app/src/locales/es/shell.json index 1f97f3e64..dcc618902 100644 --- a/app/src/locales/es/shell.json +++ b/app/src/locales/es/shell.json @@ -21,7 +21,8 @@ "needsYouCount_one": "{{count}} asunto necesita tu atención", "needsYouCount_other": "{{count}} asuntos necesitan tu atención", "runningCount_one": "{{count}} asunto en curso", - "runningCount_other": "{{count}} asuntos en curso" + "runningCount_other": "{{count}} asuntos en curso", + "usage": "Uso" }, "tabActions": { "newMission": "Nueva misión", @@ -396,5 +397,34 @@ "successWithId": "El centro de control está en eso. Referencia: {{id}}", "successNoId": "El centro de control está en eso.", "errorTitle": "No se pudo enviar el reporte" + }, + "usage": { + "title": "Uso", + "subtitle": "Costo y consumo de tokens en todos tus agentes", + "loading": "Cargando datos de uso...", + "emptyTitle": "Sin sesiones aún", + "emptySubtitle": "Inicia una conversación con cualquier agente para ver el uso aquí.", + "totalSpend": "Gasto total", + "totalSessions": "Sesiones", + "totalTokens": "Tokens", + "cachedTokens": "En caché", + "cacheEfficiency": "Aciertos de caché", + "avgCostPerSession": "Promedio / sesión", + "filterAll": "Todos los agentes", + "filterAgent": "Agente", + "filterModel": "Modelo", + "byAgent": "Por agente", + "byModel": "Por modelo", + "byHour": "Actividad por hora", + "dailyTrend": "Tendencia diaria", + "tokens": "tokens", + "cacheHit": "{{pct}}% en caché", + "captionByDay": "Tokens por día (últimos 14 días)", + "captionByDayCost": "Costo por día (últimos 14 días)", + "captionByHour": "Tokens por hora local", + "dataNote": "El costo es exacto cuando Claude lo reporta; si no, se estima con precios oficiales publicados. Gemini muestra solo tokens.", + "loadError": "Error al cargar datos de uso", + "sessions_one": "sesión", + "sessions_other": "sesiones" } } diff --git a/app/src/locales/pt/shell.json b/app/src/locales/pt/shell.json index adcde0719..cee4a5375 100644 --- a/app/src/locales/pt/shell.json +++ b/app/src/locales/pt/shell.json @@ -21,7 +21,8 @@ "needsYouCount_one": "{{count}} assunto precisa de você", "needsYouCount_other": "{{count}} assuntos precisam de você", "runningCount_one": "{{count}} assunto em execução", - "runningCount_other": "{{count}} assuntos em execução" + "runningCount_other": "{{count}} assuntos em execução", + "usage": "Uso" }, "tabActions": { "newMission": "Nova missão", @@ -396,5 +397,34 @@ "successWithId": "O centro de controle está cuidando disso. Referência: {{id}}", "successNoId": "O centro de controle está cuidando disso.", "errorTitle": "Não foi possível enviar o relatório" + }, + "usage": { + "title": "Uso", + "subtitle": "Custo e consumo de tokens em todos os seus agentes", + "loading": "Carregando dados de uso...", + "emptyTitle": "Sem sessões ainda", + "emptySubtitle": "Inicie uma conversa com qualquer agente para ver o uso aqui.", + "totalSpend": "Gasto total", + "totalSessions": "Sessões", + "totalTokens": "Tokens", + "cachedTokens": "Em cache", + "cacheEfficiency": "Acertos de cache", + "avgCostPerSession": "Média / sessão", + "filterAll": "Todos os agentes", + "filterAgent": "Agente", + "filterModel": "Modelo", + "byAgent": "Por agente", + "byModel": "Por modelo", + "byHour": "Atividade por hora", + "dailyTrend": "Tendência diária", + "tokens": "tokens", + "cacheHit": "{{pct}}% em cache", + "captionByDay": "Tokens por dia (últimos 14 dias)", + "captionByDayCost": "Custo por dia (últimos 14 dias)", + "captionByHour": "Tokens por hora local", + "dataNote": "O custo é exato quando o Claude o informa; caso contrário, é estimado com preços oficiais publicados. O Gemini mostra apenas tokens.", + "loadError": "Erro ao carregar dados de uso", + "sessions_one": "sessão", + "sessions_other": "sessões" } } diff --git a/app/tests/cost-aggregate.test.ts b/app/tests/cost-aggregate.test.ts new file mode 100644 index 000000000..a9e1e5087 --- /dev/null +++ b/app/tests/cost-aggregate.test.ts @@ -0,0 +1,230 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { aggregate, applyFilter, cacheHitPct, foldFinals, type SessionResult, type FinalResultRow } from "../src/lib/cost-aggregate.ts"; +import { calcTokenCost } from "../src/lib/token-pricing.ts"; + +function session(overrides: Partial = {}): SessionResult { + return { + agentName: "Test Agent", + agentPath: "/agents/test", + model: "claude-sonnet-4-6", + cost: 0, + hasCost: false, + totalTokens: 0, + contextTokens: 0, + cachedTokens: 0, + date: "2026-06-20", + hour: 10, + ...overrides, + }; +} + +describe("aggregate", () => { + it("returns zero totals for empty sessions", () => { + const m = aggregate([]); + assert.equal(m.totalCost, 0); + assert.equal(m.totalSessions, 0); + assert.equal(m.totalTokens, 0); + assert.equal(m.hasCostData, false); + assert.deepEqual(m.byAgent, []); + assert.deepEqual(m.byModel, []); + }); + + it("sums cost and tokens across sessions", () => { + const sessions = [ + session({ cost: 0.01, hasCost: true, totalTokens: 1000, cachedTokens: 800 }), + session({ cost: 0.02, hasCost: true, totalTokens: 2000, cachedTokens: 500 }), + ]; + const m = aggregate(sessions); + assert.ok(Math.abs(m.totalCost - 0.03) < 0.000001); + assert.equal(m.totalSessions, 2); + assert.equal(m.totalTokens, 3000); + assert.equal(m.cachedTokens, 1300); + assert.equal(m.hasCostData, true); + }); + + it("computes cache hit rate over context (input) tokens, not total tokens", () => { + // 10K context, 8K cached, 5K output. Cache hit must be 8K/10K = 80%, + // NOT 8K/15K = 53% — output tokens must not dilute the input cache rate. + const sessions = [ + session({ totalTokens: 15_000, contextTokens: 10_000, cachedTokens: 8_000 }), + ]; + const m = aggregate(sessions); + assert.equal(m.cacheEfficiencyPct, 80); + }); + + it("cacheHitPct returns 0 when there are no context tokens", () => { + assert.equal(cacheHitPct(0, 0), 0); + }); + + it("groups sessions by agent path", () => { + const sessions = [ + session({ agentPath: "/agents/a", agentName: "A", totalTokens: 100 }), + session({ agentPath: "/agents/a", agentName: "A", totalTokens: 200 }), + session({ agentPath: "/agents/b", agentName: "B", totalTokens: 50 }), + ]; + const m = aggregate(sessions); + assert.equal(m.byAgent.length, 2); + const a = m.byAgent.find((x) => x.agentPath === "/agents/a")!; + assert.equal(a.sessionCount, 2); + assert.equal(a.totalTokens, 300); + // sorted by totalTokens descending — A first + assert.equal(m.byAgent[0].agentPath, "/agents/a"); + }); + + it("groups sessions by model", () => { + const sessions = [ + session({ model: "claude-sonnet-4-6", totalTokens: 500 }), + session({ model: "claude-sonnet-4-6", totalTokens: 300 }), + session({ model: "gpt-5.3-codex", totalTokens: 100 }), + ]; + const m = aggregate(sessions); + assert.equal(m.byModel.length, 2); + assert.equal(m.byModel[0].model, "claude-sonnet-4-6"); + assert.equal(m.byModel[0].totalTokens, 800); + assert.equal(m.byModel[1].model, "gpt-5.3-codex"); + }); + + it("skips sessions with empty model in byModel", () => { + const sessions = [ + session({ model: "", totalTokens: 100 }), + session({ model: "claude-sonnet-4-6", totalTokens: 200 }), + ]; + const m = aggregate(sessions); + assert.equal(m.byModel.length, 1); + }); + + it("groups by day correctly", () => { + const sessions = [ + session({ date: "2026-06-18", totalTokens: 100 }), + session({ date: "2026-06-19", totalTokens: 200 }), + session({ date: "2026-06-18", totalTokens: 50 }), + ]; + const m = aggregate(sessions); + assert.equal(m.byDay.length, 2); + assert.equal(m.byDay[0].date, "2026-06-18"); + assert.equal(m.byDay[0].tokens, 150); + assert.equal(m.byDay[1].date, "2026-06-19"); + assert.equal(m.byDay[1].tokens, 200); + }); + + it("generates 24 hourly buckets", () => { + const sessions = [session({ hour: 9, totalTokens: 500 })]; + const m = aggregate(sessions); + assert.equal(m.byHour.length, 24); + assert.equal(m.byHour[9].tokens, 500); + assert.equal(m.byHour[9].sessions, 1); + assert.equal(m.byHour[0].tokens, 0); + }); + + it("hasCostData is false when no session has cost", () => { + const m = aggregate([session({ hasCost: false, cost: 0 })]); + assert.equal(m.hasCostData, false); + }); + + it("hasCostData is true when any session has cost", () => { + const sessions = [ + session({ hasCost: false }), + session({ hasCost: true, cost: 0.01 }), + ]; + const m = aggregate(sessions); + assert.equal(m.hasCostData, true); + }); +}); + +describe("applyFilter", () => { + const sessions = [ + session({ agentPath: "/agents/a", model: "claude-sonnet-4-6" }), + session({ agentPath: "/agents/a", model: "gpt-5.3-codex" }), + session({ agentPath: "/agents/b", model: "claude-sonnet-4-6" }), + ]; + + it("'all' returns all sessions", () => { + assert.equal(applyFilter(sessions, "all", "").length, 3); + }); + + it("'agent' filters by agentPath", () => { + const filtered = applyFilter(sessions, "agent", "/agents/a"); + assert.equal(filtered.length, 2); + assert.ok(filtered.every((s) => s.agentPath === "/agents/a")); + }); + + it("'model' filters by model name", () => { + const filtered = applyFilter(sessions, "model", "gpt-5.3-codex"); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].agentPath, "/agents/a"); + }); + + it("returns empty array when no sessions match", () => { + assert.equal(applyFilter(sessions, "model", "gemini-2.5-pro").length, 0); + }); +}); + +describe("foldFinals", () => { + it("returns zeros for no turns", () => { + const r = foldFinals([], calcTokenCost); + assert.deepEqual(r, { model: "", cost: 0, hasCost: false, totalTokens: 0, contextTokens: 0, cachedTokens: 0 }); + }); + + it("uses the CLI cost_usd when present (exact)", () => { + const finals: FinalResultRow[] = [ + { cost_usd: 0.42, model: "claude-sonnet-4-6", usage: { context_tokens: 1000, output_tokens: 100, cached_tokens: 0 } }, + ]; + const r = foldFinals(finals, calcTokenCost); + assert.equal(r.cost, 0.42); + assert.equal(r.hasCost, true); + assert.equal(r.totalTokens, 1100); + assert.equal(r.contextTokens, 1000); + }); + + it("estimates from tokens when cost_usd is absent", () => { + // Sonnet: 100K input + 10K output, no cache = (100000*3 + 10000*15)/1e6 = 0.45 + const finals: FinalResultRow[] = [ + { cost_usd: null, model: "claude-sonnet-4-6", usage: { context_tokens: 100_000, output_tokens: 10_000, cached_tokens: 0 } }, + ]; + const r = foldFinals(finals, calcTokenCost); + assert.ok(Math.abs(r.cost - 0.45) < 1e-9); + assert.equal(r.hasCost, true); + }); + + it("tracks tokens but no cost when the model is unpriced", () => { + const finals: FinalResultRow[] = [ + { cost_usd: null, model: "gemini-2.5-pro", usage: { context_tokens: 5000, output_tokens: 500, cached_tokens: 0 } }, + ]; + const r = foldFinals(finals, calcTokenCost); + assert.equal(r.cost, 0); + assert.equal(r.hasCost, false); + assert.equal(r.totalTokens, 5500); + assert.equal(r.contextTokens, 5000); + }); + + it("sums tokens and cost across multiple turns", () => { + const finals: FinalResultRow[] = [ + { cost_usd: 0.10, model: "claude-sonnet-4-6", usage: { context_tokens: 1000, output_tokens: 200, cached_tokens: 500 } }, + { cost_usd: 0.20, model: "claude-sonnet-4-6", usage: { context_tokens: 2000, output_tokens: 300, cached_tokens: 800 } }, + ]; + const r = foldFinals(finals, calcTokenCost); + assert.ok(Math.abs(r.cost - 0.30) < 1e-9); + assert.equal(r.totalTokens, 3500); + assert.equal(r.contextTokens, 3000); + assert.equal(r.cachedTokens, 1300); + }); + + it("handles turns with no usage block", () => { + const finals: FinalResultRow[] = [ + { cost_usd: 0.05, model: "claude-sonnet-4-6", usage: null }, + ]; + const r = foldFinals(finals, calcTokenCost); + assert.equal(r.cost, 0.05); + assert.equal(r.totalTokens, 0); + assert.equal(r.contextTokens, 0); + }); + + it("keeps the last turn's model as the conversation model", () => { + const finals: FinalResultRow[] = [ + { cost_usd: 0.01, model: "claude-sonnet-4-6", usage: null }, + { cost_usd: 0.01, model: "claude-opus-4-8", usage: null }, + ]; + assert.equal(foldFinals(finals, calcTokenCost).model, "claude-opus-4-8"); + }); +}); diff --git a/app/tests/date-utils.test.ts b/app/tests/date-utils.test.ts new file mode 100644 index 000000000..da161965c --- /dev/null +++ b/app/tests/date-utils.test.ts @@ -0,0 +1,18 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { isoToLocalDate } from "../src/lib/date-utils.ts"; + +describe("isoToLocalDate", () => { + it("converts a UTC ISO string to a YYYY-MM-DD local date", () => { + // Noon UTC does not shift the calendar day in any common timezone. + assert.match(isoToLocalDate("2026-06-15T12:00:00.000Z"), /^\d{4}-\d{2}-\d{2}$/); + }); + + it("returns the expected date for a midday timestamp", () => { + assert.equal(isoToLocalDate("2026-01-05T12:00:00.000Z"), "2026-01-05"); + }); + + it("zero-pads month and day", () => { + assert.match(isoToLocalDate("2026-03-07T12:00:00.000Z"), /^2026-03-07$/); + }); +}); diff --git a/app/tests/short-model.test.ts b/app/tests/short-model.test.ts new file mode 100644 index 000000000..dbde05242 --- /dev/null +++ b/app/tests/short-model.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { shortModel } from "../src/lib/usage-format.ts"; + +describe("shortModel", () => { + it("formats Claude full ids with a dot-separated version", () => { + assert.equal(shortModel("claude-sonnet-4-6"), "Sonnet 4.6"); + assert.equal(shortModel("claude-opus-4-8"), "Opus 4.8"); + assert.equal(shortModel("claude-opus-4-7"), "Opus 4.7"); + }); + + it("formats the OpenAI version id", () => { + assert.equal(shortModel("gpt-5.5"), "GPT-5.5"); + }); + + it("title-cases legacy shorthand aliases", () => { + assert.equal(shortModel("sonnet"), "Sonnet"); + assert.equal(shortModel("opus"), "Opus"); + }); +}); diff --git a/app/tests/token-pricing.test.ts b/app/tests/token-pricing.test.ts new file mode 100644 index 000000000..f41465a97 --- /dev/null +++ b/app/tests/token-pricing.test.ts @@ -0,0 +1,72 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { calcTokenCost } from "../src/lib/token-pricing.ts"; + +// Prices verified June 2026: +// Claude — platform.claude.com/docs/en/docs/about-claude/pricing +// OpenAI — developers.openai.com/api/docs/pricing + +describe("calcTokenCost", () => { + it("returns null for an unpriced model (e.g. Gemini)", () => { + assert.equal(calcTokenCost("gemini-2.5-pro", 1000, 100, 0), null); + }); + + it("computes Claude Sonnet 4.6 cost (input $3, output $15)", () => { + const cost = calcTokenCost("claude-sonnet-4-6", 100_000, 10_000, 0); + assert.ok(cost != null); + const expected = (100_000 * 3.0 + 10_000 * 15.0) / 1_000_000; + assert.ok(Math.abs(cost - expected) < 1e-9); + }); + + it("computes Claude Opus 4.8 cost (input $5, output $25)", () => { + const cost = calcTokenCost("claude-opus-4-8", 100_000, 10_000, 0); + assert.ok(cost != null); + const expected = (100_000 * 5.0 + 10_000 * 25.0) / 1_000_000; + assert.ok(Math.abs(cost - expected) < 1e-9); + }); + + it("bills Claude cached tokens at the 0.1x cache-read rate", () => { + // Sonnet: cacheRead $0.30. 50K fresh + 50K cached + 5K output. + const cost = calcTokenCost("claude-sonnet-4-6", 100_000, 5_000, 50_000); + assert.ok(cost != null); + const expected = (50_000 * 3.0 + 50_000 * 0.3 + 5_000 * 15.0) / 1_000_000; + assert.ok(Math.abs(cost - expected) < 1e-9); + }); + + it("computes gpt-5.5 cost (input $5, output $30)", () => { + const cost = calcTokenCost("gpt-5.5", 100_000, 10_000, 0); + assert.ok(cost != null); + const expected = (100_000 * 5.0 + 10_000 * 30.0) / 1_000_000; + assert.ok(Math.abs(cost - expected) < 1e-9); + }); + + it("resolves legacy 'sonnet' alias to claude-sonnet-4-6", () => { + assert.equal( + calcTokenCost("sonnet", 100_000, 10_000, 0), + calcTokenCost("claude-sonnet-4-6", 100_000, 10_000, 0), + ); + }); + + it("resolves legacy 'opus' alias to claude-opus-4-7", () => { + assert.equal( + calcTokenCost("opus", 100_000, 10_000, 0), + calcTokenCost("claude-opus-4-7", 100_000, 10_000, 0), + ); + }); + + it("resolves a suffixed model id to its base rate via prefix match", () => { + assert.equal( + calcTokenCost("claude-sonnet-4-6-20260101", 10_000, 1_000, 0), + calcTokenCost("claude-sonnet-4-6", 10_000, 1_000, 0), + ); + }); + + it("returns zero for zero tokens", () => { + assert.equal(calcTokenCost("claude-sonnet-4-6", 0, 0, 0), 0); + }); + + it("clamps fresh tokens to zero when cachedTokens exceeds contextTokens", () => { + const cost = calcTokenCost("claude-sonnet-4-6", 1_000, 100, 5_000); + assert.ok(cost != null && cost >= 0); + }); +}); diff --git a/engine/houston-agents-conversations/src/session_runner.rs b/engine/houston-agents-conversations/src/session_runner.rs index 9acd8a2eb..90d40fbfe 100644 --- a/engine/houston-agents-conversations/src/session_runner.rs +++ b/engine/houston-agents-conversations/src/session_runner.rs @@ -109,6 +109,11 @@ pub fn spawn_and_monitor( let provider_kind = provider; let provider_str = provider.to_string(); + // Keep a copy of the resolved model name for DB persistence. The value + // moves into spawn_session below and is not accessible inside the async + // block otherwise. + let model_for_persist = model.clone(); + let (mut rx, _handle) = SessionManager::spawn_session( provider, prompt, @@ -274,7 +279,7 @@ pub fn spawn_and_monitor( // Persist non-streaming items once the provider session id is known. if let Some(ref opts) = persist { if let (Some(sid), Some((ft, dj))) = - (opts.claude_session_id.as_ref(), serialize_for_persist(item)) + (opts.claude_session_id.as_ref(), serialize_for_persist(item, model_for_persist.as_deref())) { let db = opts.db.clone(); let src = opts.source.clone(); @@ -415,7 +420,11 @@ pub fn spawn_and_monitor( /// Serialize a FeedItem for DB persistence. Returns None for streaming items /// (they get replaced by their final versions). -fn serialize_for_persist(item: &FeedItem) -> Option<(String, String)> { +/// +/// `model` is injected into `final_result` rows so the analytics layer can +/// compute cost for providers (e.g. Codex) that don't report `cost_usd` +/// themselves. Absent on rows persisted before this field was added. +fn serialize_for_persist(item: &FeedItem, model: Option<&str>) -> Option<(String, String)> { match item { FeedItem::AssistantText(t) => Some(("assistant_text".into(), json_str(t))), FeedItem::UserMessage(t) => Some(("user_message".into(), json_str(t))), @@ -440,9 +449,15 @@ fn serialize_for_persist(item: &FeedItem) -> Option<(String, String)> { } => { // `usage` serializes to its TokenUsage object (or null) so the // context-usage indicator survives a history reload, same as the - // live FeedItem event. + // live FeedItem event. `model` is persisted so the analytics layer + // can compute cost for providers (e.g. Codex) that don't report + // `cost_usd` themselves. let data = serde_json::json!({ - "result": result, "cost_usd": cost_usd, "duration_ms": duration_ms, "usage": usage + "result": result, + "cost_usd": cost_usd, + "duration_ms": duration_ms, + "usage": usage, + "model": model, }); Some(("final_result".into(), data.to_string())) } @@ -710,7 +725,7 @@ mod tests { details: "exec failed".to_string(), }; - let (feed_type, data) = serialize_for_persist(&item).expect("serializes"); + let (feed_type, data) = serialize_for_persist(&item, None).expect("serializes"); assert_eq!(feed_type, "tool_runtime_error"); assert_eq!(data, r#"{"details":"exec failed","kind":"local_tool"}"#); @@ -731,13 +746,52 @@ mod tests { }), }; - let (feed_type, data) = serialize_for_persist(&item).expect("serializes"); + let (feed_type, data) = serialize_for_persist(&item, Some("claude-sonnet-4-6")).expect("serializes"); assert_eq!(feed_type, "final_result"); let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); assert_eq!(parsed["usage"]["context_tokens"], 151_500); assert_eq!(parsed["usage"]["cached_tokens"], 150_000); assert_eq!(parsed["usage"]["output_tokens"], 420); + assert_eq!(parsed["model"], "claude-sonnet-4-6"); + } + + #[test] + fn final_result_persists_model_for_cost_analytics() { + // The analytics layer needs the model to compute cost for providers + // (e.g. Codex) that don't report cost_usd. `gpt-5.5` is the OpenAI id + // Houston's picker produces. + let item = FeedItem::FinalResult { + result: "Done".to_string(), + cost_usd: None, + duration_ms: Some(3000), + usage: Some(houston_terminal_manager::TokenUsage { + context_tokens: 10_000, + output_tokens: 500, + cached_tokens: 8_000, + }), + }; + + let (_, data) = serialize_for_persist(&item, Some("gpt-5.5")).expect("serializes"); + let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); + assert_eq!(parsed["model"], "gpt-5.5"); + assert!(parsed["cost_usd"].is_null()); + } + + #[test] + fn final_result_model_null_when_not_provided() { + // Rows persisted before the model field was added (or sessions where + // the model is unknown) must not fail deserialization. + let item = FeedItem::FinalResult { + result: "Done".to_string(), + cost_usd: Some(0.05), + duration_ms: None, + usage: None, + }; + + let (_, data) = serialize_for_persist(&item, None).expect("serializes"); + let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); + assert!(parsed["model"].is_null()); } #[test] @@ -749,7 +803,7 @@ mod tests { pre_tokens: Some(185_000), }; - let (feed_type, data) = serialize_for_persist(&item).expect("serializes"); + let (feed_type, data) = serialize_for_persist(&item, None).expect("serializes"); assert_eq!(feed_type, "context_compacted"); let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); @@ -764,7 +818,7 @@ mod tests { pre_tokens: None, }; - let (feed_type, data) = serialize_for_persist(&item).expect("serializes"); + let (feed_type, data) = serialize_for_persist(&item, None).expect("serializes"); assert_eq!(feed_type, "context_compacted"); let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); @@ -781,7 +835,7 @@ mod tests { usage: None, }; - let (_, data) = serialize_for_persist(&item).expect("serializes"); + let (_, data) = serialize_for_persist(&item, None).expect("serializes"); let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); assert!(parsed["usage"].is_null()); }