story-kit: merge 301_story_dedicated_token_usage_page_in_web_ui
This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
import { Chat } from "./components/Chat";
|
import { Chat } from "./components/Chat";
|
||||||
|
import { TokenUsagePage } from "./components/TokenUsagePage";
|
||||||
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
||||||
import { usePathCompletion } from "./components/selection/usePathCompletion";
|
import { usePathCompletion } from "./components/selection/usePathCompletion";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
|
type AppView = "chat" | "token-usage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
||||||
|
const [view, setView] = React.useState<AppView>("chat");
|
||||||
const [isCheckingProject, setIsCheckingProject] = React.useState(true);
|
const [isCheckingProject, setIsCheckingProject] = React.useState(true);
|
||||||
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
||||||
const [pathInput, setPathInput] = React.useState("");
|
const [pathInput, setPathInput] = React.useState("");
|
||||||
@@ -120,6 +124,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
await api.closeProject();
|
await api.closeProject();
|
||||||
setProjectPath(null);
|
setProjectPath(null);
|
||||||
|
setView("chat");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,22 @@ export interface TokenCostResponse {
|
|||||||
agents: AgentCostEntry[];
|
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 {
|
export interface CommandOutput {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
@@ -337,6 +353,9 @@ export const api = {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getAllTokenUsage(baseUrl?: string) {
|
||||||
|
return requestJson<AllTokenUsageResponse>("/token-usage", {}, 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 });
|
||||||
|
|||||||
434
frontend/src/components/TokenUsagePage.tsx
Normal file
434
frontend/src/components/TokenUsagePage.tsx
Normal file
@@ -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 (
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
textAlign: align,
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
color: active ? "#ececec" : "#aaa",
|
||||||
|
fontWeight: active ? "700" : "500",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
onClick={() => onSort(sortKey)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{active ? (dir === "asc" ? " ↑" : " ↓") : ""}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenUsagePageProps {
|
||||||
|
projectPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenUsagePage({ projectPath }: TokenUsagePageProps) {
|
||||||
|
const [records, setRecords] = React.useState<TokenUsageRecord[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [sortKey, setSortKey] = React.useState<SortKey>("timestamp");
|
||||||
|
const [sortDir, setSortDir] = React.useState<SortDir>("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<string, number> = {};
|
||||||
|
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<string, number> = {};
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "#111",
|
||||||
|
padding: "24px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#ececec",
|
||||||
|
margin: "0 0 20px",
|
||||||
|
fontSize: "1.1em",
|
||||||
|
fontWeight: "700",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Token Usage
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Summary totals */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "16px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
marginBottom: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SummaryCard
|
||||||
|
label="Total Cost"
|
||||||
|
value={formatCost(totalCost)}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
{Object.entries(byAgentType)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([type, cost]) => (
|
||||||
|
<SummaryCard
|
||||||
|
key={type}
|
||||||
|
label={`${type.charAt(0).toUpperCase()}${type.slice(1)}`}
|
||||||
|
value={formatCost(cost)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{Object.entries(byModel)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([model, cost]) => (
|
||||||
|
<SummaryCard key={model} label={model} value={formatCost(cost)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<p style={{ color: "#555", fontSize: "0.9em" }}>Loading...</p>
|
||||||
|
)}
|
||||||
|
{error && <p style={{ color: "#e05c5c", fontSize: "0.9em" }}>{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && records.length === 0 && (
|
||||||
|
<p style={{ color: "#555", fontSize: "0.9em" }}>
|
||||||
|
No token usage records found.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && records.length > 0 && (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "#1a1a1a" }}>
|
||||||
|
<SortHeader
|
||||||
|
label="Date"
|
||||||
|
sortKey="timestamp"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Story"
|
||||||
|
sortKey="story_id"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Agent"
|
||||||
|
sortKey="agent_name"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Model"
|
||||||
|
sortKey="model"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#aaa",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Input
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#aaa",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cache+
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#aaa",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cache↩
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#aaa",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Output
|
||||||
|
</th>
|
||||||
|
<SortHeader
|
||||||
|
label="Cost"
|
||||||
|
sortKey="total_cost_usd"
|
||||||
|
current={sortKey}
|
||||||
|
dir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((r, i) => (
|
||||||
|
<tr
|
||||||
|
key={`${r.story_id}-${r.agent_name}-${r.timestamp}`}
|
||||||
|
style={{ background: i % 2 === 0 ? "#111" : "#161616" }}
|
||||||
|
>
|
||||||
|
<td style={cellStyle}>{formatTimestamp(r.timestamp)}</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
color: "#8b9cf7",
|
||||||
|
maxWidth: "220px",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.story_id}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, color: "#7ec8a4" }}>
|
||||||
|
{r.agent_name}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, color: "#c9a96e" }}>
|
||||||
|
{r.model ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||||
|
{formatTokens(r.input_tokens)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||||
|
{formatTokens(r.cache_creation_input_tokens)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||||
|
{formatTokens(r.cache_read_input_tokens)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||||
|
{formatTokens(r.output_tokens)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...cellStyle,
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#e08c5c",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCost(r.total_cost_usd)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight = false,
|
||||||
|
}: { label: string; value: string; highlight?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: highlight ? "#1e1e2e" : "#1a1a1a",
|
||||||
|
border: `1px solid ${highlight ? "#3a3a5a" : "#2a2a2a"}`,
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "12px 16px",
|
||||||
|
minWidth: "120px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7em",
|
||||||
|
color: "#666",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.07em",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "1.1em",
|
||||||
|
fontWeight: "700",
|
||||||
|
color: highlight ? "#c9a96e" : "#ececec",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -507,8 +507,11 @@ impl AgentPool {
|
|||||||
&& let Some(agent) = agents.get(&key_clone)
|
&& let Some(agent) = agents.get(&key_clone)
|
||||||
&& let Some(ref pr) = agent.project_root
|
&& 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(
|
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) {
|
if let Err(e) = super::token_usage::append_record(pr, &record) {
|
||||||
slog_error!(
|
slog_error!(
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub struct TokenUsageRecord {
|
|||||||
pub story_id: String,
|
pub story_id: String,
|
||||||
pub agent_name: String,
|
pub agent_name: String,
|
||||||
pub timestamp: String,
|
pub timestamp: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
pub usage: TokenUsage,
|
pub usage: TokenUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +71,17 @@ pub fn read_all(project_root: &Path) -> Result<Vec<TokenUsageRecord>, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `TokenUsageRecord` from the parts available at completion time.
|
/// 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<String>,
|
||||||
|
usage: TokenUsage,
|
||||||
|
) -> TokenUsageRecord {
|
||||||
TokenUsageRecord {
|
TokenUsageRecord {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
agent_name: agent_name.to_string(),
|
agent_name: agent_name.to_string(),
|
||||||
timestamp: Utc::now().to_rfc3339(),
|
timestamp: Utc::now().to_rfc3339(),
|
||||||
|
model,
|
||||||
usage,
|
usage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +110,7 @@ mod tests {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let root = dir.path();
|
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();
|
append_record(root, &record).unwrap();
|
||||||
|
|
||||||
let records = read_all(root).unwrap();
|
let records = read_all(root).unwrap();
|
||||||
@@ -117,8 +125,8 @@ mod tests {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let root = dir.path();
|
let root = dir.path();
|
||||||
|
|
||||||
let r1 = build_record("s1", "coder-1", sample_usage());
|
let r1 = build_record("s1", "coder-1", None, sample_usage());
|
||||||
let r2 = build_record("s2", "coder-2", sample_usage());
|
let r2 = build_record("s2", "coder-2", None, sample_usage());
|
||||||
append_record(root, &r1).unwrap();
|
append_record(root, &r1).unwrap();
|
||||||
append_record(root, &r2).unwrap();
|
append_record(root, &r2).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,26 @@ struct TokenCostResponse {
|
|||||||
agents: Vec<AgentCostEntry>,
|
agents: Vec<AgentCostEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single token usage record in the all-usage response.
|
||||||
|
#[derive(Object, Serialize)]
|
||||||
|
struct TokenUsageRecordResponse {
|
||||||
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
model: Option<String>,
|
||||||
|
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<TokenUsageRecordResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 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`
|
||||||
@@ -532,6 +552,42 @@ impl AgentsApi {
|
|||||||
agents,
|
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<Json<AllTokenUsageResponse>> {
|
||||||
|
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<TokenUsageRecordResponse> = 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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -3862,7 +3862,7 @@ mod tests {
|
|||||||
total_cost_usd: 1.57,
|
total_cost_usd: 1.57,
|
||||||
};
|
};
|
||||||
let record =
|
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();
|
crate::agents::token_usage::append_record(root, &record).unwrap();
|
||||||
|
|
||||||
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
||||||
@@ -3888,8 +3888,8 @@ mod tests {
|
|||||||
cache_read_input_tokens: 0,
|
cache_read_input_tokens: 0,
|
||||||
total_cost_usd: 0.5,
|
total_cost_usd: 0.5,
|
||||||
};
|
};
|
||||||
let r1 = crate::agents::token_usage::build_record("10_story_a", "coder-1", usage.clone());
|
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", usage);
|
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, &r1).unwrap();
|
||||||
crate::agents::token_usage::append_record(root, &r2).unwrap();
|
crate::agents::token_usage::append_record(root, &r2).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -1194,6 +1194,7 @@ mod tests {
|
|||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
agent_name: agent_name.to_string(),
|
agent_name: agent_name.to_string(),
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
|
model: None,
|
||||||
usage: make_usage(cost),
|
usage: make_usage(cost),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user