story-kit: merge 300_story_show_token_cost_badge_on_pipeline_board_work_items
This commit is contained in:
@@ -143,6 +143,20 @@ export interface SearchResult {
|
|||||||
matches: number;
|
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 {
|
export interface CommandOutput {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
@@ -316,6 +330,13 @@ export const api = {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getTokenCost(storyId: string, baseUrl?: string) {
|
||||||
|
return requestJson<TokenCostResponse>(
|
||||||
|
`/work-items/${encodeURIComponent(storyId)}/token-cost`,
|
||||||
|
{},
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
/** Approve a story in QA, moving it to merge. */
|
/** Approve a story in QA, moving it to merge. */
|
||||||
approveQa(storyId: string) {
|
approveQa(storyId: string) {
|
||||||
return callMcpTool("approve_qa", { story_id: storyId });
|
return callMcpTool("approve_qa", { story_id: storyId });
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [agentConfigVersion, setAgentConfigVersion] = useState(0);
|
const [agentConfigVersion, setAgentConfigVersion] = useState(0);
|
||||||
const [agentStateVersion, setAgentStateVersion] = useState(0);
|
const [agentStateVersion, setAgentStateVersion] = useState(0);
|
||||||
const [pipelineVersion, setPipelineVersion] = useState(0);
|
const [pipelineVersion, setPipelineVersion] = useState(0);
|
||||||
|
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
const onboardingTriggeredRef = useRef(false);
|
const onboardingTriggeredRef = useRef(false);
|
||||||
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
||||||
@@ -363,6 +366,29 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
onPipelineState: (state) => {
|
onPipelineState: (state) => {
|
||||||
setPipeline(state);
|
setPipeline(state);
|
||||||
setPipelineVersion((v) => v + 1);
|
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) => {
|
onPermissionRequest: (requestId, toolName, toolInput) => {
|
||||||
setPermissionQueue((prev) => [
|
setPermissionQueue((prev) => [
|
||||||
@@ -1005,26 +1031,31 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
<StagePanel
|
<StagePanel
|
||||||
title="Done"
|
title="Done"
|
||||||
items={pipeline.done ?? []}
|
items={pipeline.done ?? []}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="To Merge"
|
title="To Merge"
|
||||||
items={pipeline.merge}
|
items={pipeline.merge}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="QA"
|
title="QA"
|
||||||
items={pipeline.qa}
|
items={pipeline.qa}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="Current"
|
title="Current"
|
||||||
items={pipeline.current}
|
items={pipeline.current}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="Backlog"
|
title="Backlog"
|
||||||
items={pipeline.backlog}
|
items={pipeline.backlog}
|
||||||
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
<ServerLogsPanel logs={serverLogs} />
|
<ServerLogsPanel logs={serverLogs} />
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ interface StagePanelProps {
|
|||||||
items: PipelineStageItem[];
|
items: PipelineStageItem[];
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
onItemClick?: (item: PipelineStageItem) => void;
|
onItemClick?: (item: PipelineStageItem) => void;
|
||||||
|
/** Map of story_id → total_cost_usd for displaying cost badges. */
|
||||||
|
costs?: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentLozenge({
|
function AgentLozenge({
|
||||||
@@ -128,6 +130,7 @@ export function StagePanel({
|
|||||||
items,
|
items,
|
||||||
emptyMessage = "Empty.",
|
emptyMessage = "Empty.",
|
||||||
onItemClick,
|
onItemClick,
|
||||||
|
costs,
|
||||||
}: StagePanelProps) {
|
}: StagePanelProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -240,6 +243,19 @@ export function StagePanel({
|
|||||||
{typeLabel}
|
{typeLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{costs?.has(item.story_id) && (
|
||||||
|
<span
|
||||||
|
data-testid={`cost-badge-${item.story_id}`}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.65em",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#e3b341",
|
||||||
|
marginRight: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${costs.get(item.story_id)?.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{item.name ?? item.story_id}
|
{item.name ?? item.story_id}
|
||||||
</div>
|
</div>
|
||||||
{item.error && (
|
{item.error && (
|
||||||
|
|||||||
@@ -112,6 +112,24 @@ struct AgentOutputResponse {
|
|||||||
output: String,
|
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<AgentCostEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`.
|
/// 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`
|
/// Used to exclude agents for already-archived stories from the `list_agents`
|
||||||
@@ -463,6 +481,57 @@ impl AgentsApi {
|
|||||||
|
|
||||||
Ok(Json(true))
|
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<String>,
|
||||||
|
) -> OpenApiResult<Json<TokenCostResponse>> {
|
||||||
|
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<String, AgentCostEntry> =
|
||||||
|
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<AgentCostEntry> = 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)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user