diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fb0e49f..fc02bf0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -143,6 +143,20 @@ export interface SearchResult { matches: number; } +export interface AgentCostEntry { + agent_name: string; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + total_cost_usd: number; +} + +export interface TokenCostResponse { + total_cost_usd: number; + agents: AgentCostEntry[]; +} + export interface CommandOutput { stdout: string; stderr: string; @@ -316,6 +330,13 @@ export const api = { baseUrl, ); }, + getTokenCost(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}/token-cost`, + {}, + 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/Chat.tsx b/frontend/src/components/Chat.tsx index a0709b0..410dfed 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -202,6 +202,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [agentConfigVersion, setAgentConfigVersion] = useState(0); const [agentStateVersion, setAgentStateVersion] = useState(0); const [pipelineVersion, setPipelineVersion] = useState(0); + const [storyTokenCosts, setStoryTokenCosts] = useState>( + new Map(), + ); const [needsOnboarding, setNeedsOnboarding] = useState(false); const onboardingTriggeredRef = useRef(false); const [selectedWorkItemId, setSelectedWorkItemId] = useState( @@ -363,6 +366,29 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { onPipelineState: (state) => { setPipeline(state); setPipelineVersion((v) => v + 1); + const allItems = [ + ...state.backlog, + ...state.current, + ...state.qa, + ...state.merge, + ...state.done, + ]; + for (const item of allItems) { + api + .getTokenCost(item.story_id) + .then((cost) => { + if (cost.total_cost_usd > 0) { + setStoryTokenCosts((prev) => { + const next = new Map(prev); + next.set(item.story_id, cost.total_cost_usd); + return next; + }); + } + }) + .catch(() => { + // Silently ignore — cost data may not exist yet. + }); + } }, onPermissionRequest: (requestId, toolName, toolInput) => { setPermissionQueue((prev) => [ @@ -1005,26 +1031,31 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { setSelectedWorkItemId(item.story_id)} /> setSelectedWorkItemId(item.story_id)} /> setSelectedWorkItemId(item.story_id)} /> setSelectedWorkItemId(item.story_id)} /> setSelectedWorkItemId(item.story_id)} /> diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index ecd5879..91ae51a 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -42,6 +42,8 @@ interface StagePanelProps { items: PipelineStageItem[]; emptyMessage?: string; onItemClick?: (item: PipelineStageItem) => void; + /** Map of story_id → total_cost_usd for displaying cost badges. */ + costs?: Map; } function AgentLozenge({ @@ -128,6 +130,7 @@ export function StagePanel({ items, emptyMessage = "Empty.", onItemClick, + costs, }: StagePanelProps) { return (
)} + {costs?.has(item.story_id) && ( + + ${costs.get(item.story_id)?.toFixed(2)} + + )} {item.name ?? item.story_id}
{item.error && ( diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs index d112fb6..ba7f177 100644 --- a/server/src/http/agents.rs +++ b/server/src/http/agents.rs @@ -112,6 +112,24 @@ struct AgentOutputResponse { output: String, } +/// Per-agent cost breakdown entry for the token cost endpoint. +#[derive(Object, Serialize)] +struct AgentCostEntry { + agent_name: String, + input_tokens: u64, + output_tokens: u64, + cache_creation_input_tokens: u64, + cache_read_input_tokens: u64, + total_cost_usd: f64, +} + +/// Response for the work item token cost endpoint. +#[derive(Object, Serialize)] +struct TokenCostResponse { + total_cost_usd: f64, + agents: 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` @@ -463,6 +481,57 @@ impl AgentsApi { Ok(Json(true)) } + + /// Get the total token cost and per-agent breakdown for a work item. + /// + /// Returns the sum of all recorded token usage for the given story_id. + /// If no usage has been recorded, returns zero cost with an empty agents list. + #[oai(path = "/work-items/:story_id/token-cost", method = "get")] + async fn get_work_item_token_cost( + &self, + story_id: Path, + ) -> OpenApiResult> { + let project_root = self + .ctx + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let all_records = crate::agents::token_usage::read_all(&project_root) + .map_err(|e| bad_request(format!("Failed to read token usage: {e}")))?; + + let mut agent_map: std::collections::HashMap = + std::collections::HashMap::new(); + + let mut total_cost_usd = 0.0_f64; + + for record in all_records.into_iter().filter(|r| r.story_id == story_id.0) { + total_cost_usd += record.usage.total_cost_usd; + let entry = agent_map + .entry(record.agent_name.clone()) + .or_insert_with(|| AgentCostEntry { + agent_name: record.agent_name.clone(), + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + total_cost_usd: 0.0, + }); + entry.input_tokens += record.usage.input_tokens; + entry.output_tokens += record.usage.output_tokens; + entry.cache_creation_input_tokens += record.usage.cache_creation_input_tokens; + entry.cache_read_input_tokens += record.usage.cache_read_input_tokens; + entry.total_cost_usd += record.usage.total_cost_usd; + } + + let mut agents: Vec = agent_map.into_values().collect(); + agents.sort_by(|a, b| a.agent_name.cmp(&b.agent_name)); + + Ok(Json(TokenCostResponse { + total_cost_usd, + agents, + })) + } } #[cfg(test)]