diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3a3eab6..bfa8beb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,16 @@ import * as React from "react"; import { api } from "./api/client"; import { Chat } from "./components/Chat"; +import { TokenUsagePage } from "./components/TokenUsagePage"; import { SelectionScreen } from "./components/selection/SelectionScreen"; import { usePathCompletion } from "./components/selection/usePathCompletion"; import "./App.css"; +type AppView = "chat" | "token-usage"; + function App() { const [projectPath, setProjectPath] = React.useState(null); + const [view, setView] = React.useState("chat"); const [isCheckingProject, setIsCheckingProject] = React.useState(true); const [errorMsg, setErrorMsg] = React.useState(null); const [pathInput, setPathInput] = React.useState(""); @@ -120,6 +124,7 @@ function App() { try { await api.closeProject(); setProjectPath(null); + setView("chat"); } catch (e) { console.error(e); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fc02bf0..e2ed9e1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -157,6 +157,22 @@ export interface TokenCostResponse { agents: AgentCostEntry[]; } +export interface TokenUsageRecord { + story_id: string; + agent_name: string; + model: string | null; + timestamp: string; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + total_cost_usd: number; +} + +export interface AllTokenUsageResponse { + records: TokenUsageRecord[]; +} + export interface CommandOutput { stdout: string; stderr: string; @@ -337,6 +353,9 @@ export const api = { baseUrl, ); }, + getAllTokenUsage(baseUrl?: string) { + return requestJson("/token-usage", {}, baseUrl); + }, /** Approve a story in QA, moving it to merge. */ approveQa(storyId: string) { return callMcpTool("approve_qa", { story_id: storyId }); diff --git a/frontend/src/components/TokenUsagePage.tsx b/frontend/src/components/TokenUsagePage.tsx new file mode 100644 index 0000000..9b32f40 --- /dev/null +++ b/frontend/src/components/TokenUsagePage.tsx @@ -0,0 +1,434 @@ +import * as React from "react"; +import { api } from "../api/client"; +import type { TokenUsageRecord } from "../api/client"; + +type SortKey = + | "timestamp" + | "story_id" + | "agent_name" + | "model" + | "total_cost_usd"; +type SortDir = "asc" | "desc"; + +function formatCost(usd: number): string { + if (usd === 0) return "$0.00"; + if (usd < 0.001) return `$${usd.toFixed(6)}`; + if (usd < 0.01) return `$${usd.toFixed(4)}`; + return `$${usd.toFixed(3)}`; +} + +function formatTokens(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); +} + +function formatTimestamp(iso: string): string { + const d = new Date(iso); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const h = String(d.getHours()).padStart(2, "0"); + const m = String(d.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day} ${h}:${m}`; +} + +/** Infer an agent type from the agent name. */ +function agentType(agentName: string): string { + const lower = agentName.toLowerCase(); + if (lower.startsWith("coder")) return "coder"; + if (lower.startsWith("qa")) return "qa"; + if (lower.startsWith("mergemaster") || lower.startsWith("merge")) + return "mergemaster"; + return "other"; +} + +interface SortHeaderProps { + label: string; + sortKey: SortKey; + current: SortKey; + dir: SortDir; + onSort: (key: SortKey) => void; + align?: "left" | "right"; +} + +function SortHeader({ + label, + sortKey, + current, + dir, + onSort, + align = "left", +}: SortHeaderProps) { + const active = current === sortKey; + return ( + onSort(sortKey)} + > + {label} + {active ? (dir === "asc" ? " ↑" : " ↓") : ""} + + ); +} + +interface TokenUsagePageProps { + projectPath: string; +} + +export function TokenUsagePage({ projectPath }: TokenUsagePageProps) { + const [records, setRecords] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [sortKey, setSortKey] = React.useState("timestamp"); + const [sortDir, setSortDir] = React.useState("desc"); + + React.useEffect(() => { + setLoading(true); + setError(null); + api + .getAllTokenUsage() + .then((resp) => setRecords(resp.records)) + .catch((e) => + setError(e instanceof Error ? e.message : "Failed to load token usage"), + ) + .finally(() => setLoading(false)); + }, [projectPath]); + + function handleSort(key: SortKey) { + if (key === sortKey) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir(key === "timestamp" ? "desc" : "asc"); + } + } + + const sorted = React.useMemo(() => { + return [...records].sort((a, b) => { + let cmp = 0; + switch (sortKey) { + case "timestamp": + cmp = a.timestamp.localeCompare(b.timestamp); + break; + case "story_id": + cmp = a.story_id.localeCompare(b.story_id); + break; + case "agent_name": + cmp = a.agent_name.localeCompare(b.agent_name); + break; + case "model": + cmp = (a.model ?? "").localeCompare(b.model ?? ""); + break; + case "total_cost_usd": + cmp = a.total_cost_usd - b.total_cost_usd; + break; + } + return sortDir === "asc" ? cmp : -cmp; + }); + }, [records, sortKey, sortDir]); + + // Compute summary totals + const totalCost = records.reduce((s, r) => s + r.total_cost_usd, 0); + + const byAgentType = React.useMemo(() => { + const map: Record = {}; + for (const r of records) { + const t = agentType(r.agent_name); + map[t] = (map[t] ?? 0) + r.total_cost_usd; + } + return map; + }, [records]); + + const byModel = React.useMemo(() => { + const map: Record = {}; + for (const r of records) { + const m = r.model ?? "unknown"; + map[m] = (map[m] ?? 0) + r.total_cost_usd; + } + return map; + }, [records]); + + const cellStyle: React.CSSProperties = { + padding: "7px 12px", + borderBottom: "1px solid #222", + fontSize: "0.85em", + color: "#ccc", + whiteSpace: "nowrap", + }; + + return ( +
+

+ Token Usage +

+ + {/* Summary totals */} +
+ + {Object.entries(byAgentType) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([type, cost]) => ( + + ))} + {Object.entries(byModel) + .sort(([, a], [, b]) => b - a) + .map(([model, cost]) => ( + + ))} +
+ + {loading && ( +

Loading...

+ )} + {error &&

{error}

} + + {!loading && !error && records.length === 0 && ( +

+ No token usage records found. +

+ )} + + {!loading && !error && records.length > 0 && ( +
+ + + + + + + + + + + + + + + + {sorted.map((r, i) => ( + + + + + + + + + + + + ))} + +
+ Input + + Cache+ + + Cache↩ + + Output +
{formatTimestamp(r.timestamp)} + {r.story_id} + + {r.agent_name} + + {r.model ?? "—"} + + {formatTokens(r.input_tokens)} + + {formatTokens(r.cache_creation_input_tokens)} + + {formatTokens(r.cache_read_input_tokens)} + + {formatTokens(r.output_tokens)} + + {formatCost(r.total_cost_usd)} +
+
+ )} +
+ ); +} + +function SummaryCard({ + label, + value, + highlight = false, +}: { label: string; value: string; highlight?: boolean }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/server/src/agents/pool.rs b/server/src/agents/pool.rs index 137a7f0..059db77 100644 --- a/server/src/agents/pool.rs +++ b/server/src/agents/pool.rs @@ -507,8 +507,11 @@ impl AgentPool { && let Some(agent) = agents.get(&key_clone) && let Some(ref pr) = agent.project_root { + let model = config_clone + .find_agent(&aname) + .and_then(|a| a.model.clone()); let record = super::token_usage::build_record( - &sid, &aname, usage.clone(), + &sid, &aname, model, usage.clone(), ); if let Err(e) = super::token_usage::append_record(pr, &record) { slog_error!( diff --git a/server/src/agents/token_usage.rs b/server/src/agents/token_usage.rs index 86a8831..b1ae0a1 100644 --- a/server/src/agents/token_usage.rs +++ b/server/src/agents/token_usage.rs @@ -12,6 +12,8 @@ pub struct TokenUsageRecord { pub story_id: String, pub agent_name: String, pub timestamp: String, + #[serde(default)] + pub model: Option, pub usage: TokenUsage, } @@ -69,11 +71,17 @@ pub fn read_all(project_root: &Path) -> Result, String> { } /// Build a `TokenUsageRecord` from the parts available at completion time. -pub fn build_record(story_id: &str, agent_name: &str, usage: TokenUsage) -> TokenUsageRecord { +pub fn build_record( + story_id: &str, + agent_name: &str, + model: Option, + usage: TokenUsage, +) -> TokenUsageRecord { TokenUsageRecord { story_id: story_id.to_string(), agent_name: agent_name.to_string(), timestamp: Utc::now().to_rfc3339(), + model, usage, } } @@ -102,7 +110,7 @@ mod tests { let dir = TempDir::new().unwrap(); let root = dir.path(); - let record = build_record("42_story_foo", "coder-1", sample_usage()); + let record = build_record("42_story_foo", "coder-1", None, sample_usage()); append_record(root, &record).unwrap(); let records = read_all(root).unwrap(); @@ -117,8 +125,8 @@ mod tests { let dir = TempDir::new().unwrap(); let root = dir.path(); - let r1 = build_record("s1", "coder-1", sample_usage()); - let r2 = build_record("s2", "coder-2", sample_usage()); + let r1 = build_record("s1", "coder-1", None, sample_usage()); + let r2 = build_record("s2", "coder-2", None, sample_usage()); append_record(root, &r1).unwrap(); append_record(root, &r2).unwrap(); diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs index ba7f177..21b02a4 100644 --- a/server/src/http/agents.rs +++ b/server/src/http/agents.rs @@ -130,6 +130,26 @@ struct TokenCostResponse { agents: Vec, } +/// A single token usage record in the all-usage response. +#[derive(Object, Serialize)] +struct TokenUsageRecordResponse { + story_id: String, + agent_name: String, + model: Option, + timestamp: String, + input_tokens: u64, + output_tokens: u64, + cache_creation_input_tokens: u64, + cache_read_input_tokens: u64, + total_cost_usd: f64, +} + +/// Response for the all token usage endpoint. +#[derive(Object, Serialize)] +struct AllTokenUsageResponse { + records: Vec, +} + /// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`. /// /// Used to exclude agents for already-archived stories from the `list_agents` @@ -532,6 +552,42 @@ impl AgentsApi { agents, })) } + + /// Get all token usage records across all stories. + /// + /// Returns the full history from the persistent token_usage.jsonl log. + #[oai(path = "/token-usage", method = "get")] + async fn get_all_token_usage( + &self, + ) -> OpenApiResult> { + let project_root = self + .ctx + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let records = crate::agents::token_usage::read_all(&project_root) + .map_err(|e| bad_request(format!("Failed to read token usage: {e}")))?; + + let response_records: Vec = records + .into_iter() + .map(|r| TokenUsageRecordResponse { + story_id: r.story_id, + agent_name: r.agent_name, + model: r.model, + timestamp: r.timestamp, + input_tokens: r.usage.input_tokens, + output_tokens: r.usage.output_tokens, + cache_creation_input_tokens: r.usage.cache_creation_input_tokens, + cache_read_input_tokens: r.usage.cache_read_input_tokens, + total_cost_usd: r.usage.total_cost_usd, + }) + .collect(); + + Ok(Json(AllTokenUsageResponse { + records: response_records, + })) + } } #[cfg(test)] diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 972e858..85e28d6 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -3862,7 +3862,7 @@ mod tests { total_cost_usd: 1.57, }; let record = - crate::agents::token_usage::build_record("42_story_foo", "coder-1", usage); + crate::agents::token_usage::build_record("42_story_foo", "coder-1", None, usage); crate::agents::token_usage::append_record(root, &record).unwrap(); let result = tool_get_token_usage(&json!({}), &ctx).unwrap(); @@ -3888,8 +3888,8 @@ mod tests { cache_read_input_tokens: 0, total_cost_usd: 0.5, }; - let r1 = crate::agents::token_usage::build_record("10_story_a", "coder-1", usage.clone()); - let r2 = crate::agents::token_usage::build_record("20_story_b", "coder-2", usage); + let r1 = crate::agents::token_usage::build_record("10_story_a", "coder-1", None, usage.clone()); + let r2 = crate::agents::token_usage::build_record("20_story_b", "coder-2", None, usage); crate::agents::token_usage::append_record(root, &r1).unwrap(); crate::agents::token_usage::append_record(root, &r2).unwrap(); diff --git a/server/src/matrix/commands.rs b/server/src/matrix/commands.rs index 9697b4d..f1fdff8 100644 --- a/server/src/matrix/commands.rs +++ b/server/src/matrix/commands.rs @@ -1194,6 +1194,7 @@ mod tests { story_id: story_id.to_string(), agent_name: agent_name.to_string(), timestamp: ts, + model: None, usage: make_usage(cost), } }