Skip to content

Commit 34da480

Browse files
yarin-magclaude
andcommitted
feat(conversation): show per-message token costs and conversation totals in Conversation tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 949eb55 commit 34da480

5 files changed

Lines changed: 74 additions & 16 deletions

File tree

apps/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "marionette-server",
3-
"version": "1.2.5",
3+
"version": "1.2.6",
44
"private": true,
55
"type": "module",
66
"bin": {

apps/server/src/controllers/agents.controller.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,25 +80,51 @@ export class AgentsController {
8080
eventRepo.findWithFilters({ agentId, type: "llm.call", limit: 1000 }),
8181
]);
8282

83-
// Build lookup: "run_id:ts" → tokens
84-
const llmMap = new Map<string, any>();
83+
// Build per-run ordered list of llm.call token objects (sorted oldest→newest).
84+
// The Nth llm.call in a run corresponds to the Nth assistant conversation.turn in that run.
85+
const llmByRun = new Map<string, Array<{ tokens: unknown; ts: string }>>();
8586
for (const e of llmEvents) {
86-
if (e.tokens && e.run_id && e.ts) {
87-
llmMap.set(`${e.run_id}:${e.ts}`, e.tokens);
88-
}
87+
if (!e.run_id || !e.tokens) continue;
88+
if (!llmByRun.has(e.run_id)) llmByRun.set(e.run_id, []);
89+
llmByRun.get(e.run_id)!.push({ tokens: e.tokens, ts: e.ts ?? "" });
90+
}
91+
for (const arr of llmByRun.values()) {
92+
arr.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
8993
}
9094

95+
// Track how many assistant turns we've matched per run so we can index into the llm list.
96+
const assistantCountByRun = new Map<string, number>();
97+
9198
const turns = turnEvents.reverse().flatMap((e) => {
9299
const rawTurn = e.payload as Record<string, unknown> | null;
93100
if (!rawTurn || typeof rawTurn.role !== "string") return []; // skip malformed
94101
const turn = rawTurn as unknown as ConversationTurn;
95-
if (turn.role === "assistant") {
96-
const tokens = llmMap.get(`${e.run_id}:${e.ts}`);
97-
if (tokens) return [{ ...turn, tokens }];
102+
if (turn.role === "assistant" && e.run_id) {
103+
const idx = assistantCountByRun.get(e.run_id) ?? 0;
104+
assistantCountByRun.set(e.run_id, idx + 1);
105+
const llmEntry = llmByRun.get(e.run_id)?.[idx];
106+
if (llmEntry) return [{ ...turn, tokens: llmEntry.tokens }];
98107
}
99108
return [turn];
100109
});
101110

102-
res.json({ turns, total: turns.length });
111+
// Compute conversation-level token totals across all matched llm.call events.
112+
const allRunIds = [...new Set(turnEvents.map((e) => e.run_id).filter(Boolean))];
113+
let totalInputTokens = 0, totalOutputTokens = 0, totalCostUsd = 0;
114+
for (const runId of allRunIds) {
115+
for (const entry of llmByRun.get(runId as string) ?? []) {
116+
const t = entry.tokens as Record<string, number> | null;
117+
if (!t) continue;
118+
totalInputTokens += t.input_tokens ?? 0;
119+
totalOutputTokens += t.output_tokens ?? 0;
120+
totalCostUsd += t.cost_usd ?? 0;
121+
}
122+
}
123+
124+
res.json({
125+
turns,
126+
total: turns.length,
127+
totals: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens, cost_usd: totalCostUsd },
128+
});
103129
}
104130
}

apps/web/src/features/agents/components/AgentConversationPanel.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@ import { useEffect, useRef } from 'react';
22
import { Loader2, AlertCircle } from 'lucide-react';
33
import { useAgentConversation } from '../hooks/useAgentConversation';
44
import { ConversationMessage } from './ConversationMessage';
5+
import { formatTokens } from '../../../lib/utils';
6+
7+
function formatCost(usd: number): string {
8+
if (!isFinite(usd) || usd === 0) return "$0.00";
9+
if (usd < 0.001) return `$${usd.toFixed(6)}`;
10+
if (usd < 0.01) return `$${usd.toFixed(4)}`;
11+
return `$${usd.toFixed(3)}`;
12+
}
513

614
interface AgentConversationPanelProps {
715
agentId: string;
816
}
917

1018
export function AgentConversationPanel({ agentId }: AgentConversationPanelProps) {
11-
const { turns, isLoading, error } = useAgentConversation(agentId);
19+
const { turns, totals, isLoading, error } = useAgentConversation(agentId);
1220
const messagesEndRef = useRef<HTMLDivElement>(null);
1321

1422
useEffect(() => {
@@ -26,14 +34,29 @@ export function AgentConversationPanel({ agentId }: AgentConversationPanelProps)
2634
);
2735
}
2836

37+
const hasTotals = totals && (totals.input_tokens > 0 || totals.output_tokens > 0);
38+
2939
return (
3040
<div className="flex flex-col h-full">
3141
{/* Header */}
3242
<div className="p-4 border-b bg-card">
3343
<h2 className="text-lg font-semibold">Conversation</h2>
34-
<p className="text-sm text-muted-foreground">
35-
{turns.length} {turns.length === 1 ? 'message' : 'messages'}
36-
</p>
44+
<div className="flex items-center gap-3 flex-wrap">
45+
<p className="text-sm text-muted-foreground">
46+
{turns.length} {turns.length === 1 ? 'message' : 'messages'}
47+
</p>
48+
{hasTotals && (
49+
<>
50+
<span className="text-muted-foreground/40 text-xs">·</span>
51+
<span className="text-xs text-muted-foreground tabular-nums">
52+
{formatTokens(totals.input_tokens)} · ↓ {formatTokens(totals.output_tokens)}
53+
</span>
54+
<span className="text-xs text-emerald-500/80 tabular-nums font-medium">
55+
{formatCost(totals.cost_usd)}
56+
</span>
57+
</>
58+
)}
59+
</div>
3760
</div>
3861

3962
{/* Error banner */}

apps/web/src/features/agents/hooks/useAgentConversation.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,23 @@ export interface EnrichedConversationTurn extends ConversationTurn {
99
tokens?: TokenUsage;
1010
}
1111

12+
export interface ConversationTotals {
13+
input_tokens: number;
14+
output_tokens: number;
15+
cost_usd: number;
16+
}
17+
1218
interface UseAgentConversationResult {
1319
turns: EnrichedConversationTurn[];
20+
totals: ConversationTotals | null;
1421
isLoading: boolean;
1522
error: string | null;
1623
}
1724

1825
export function useAgentConversation(agentId: string): UseAgentConversationResult {
1926
const isDemoMode = useDemoMode();
2027
const [turns, setTurns] = useState<EnrichedConversationTurn[]>([]);
28+
const [totals, setTotals] = useState<ConversationTotals | null>(null);
2129
const [isLoading, setIsLoading] = useState(true);
2230
const [error, setError] = useState<string | null>(null);
2331

@@ -37,6 +45,7 @@ export function useAgentConversation(agentId: string): UseAgentConversationResul
3745
const data = await res.json();
3846
if (cancelled) return;
3947
setTurns(data.turns || []);
48+
setTotals(data.totals ?? null);
4049
} catch (err) {
4150
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load conversation');
4251
} finally {
@@ -48,5 +57,5 @@ export function useAgentConversation(agentId: string): UseAgentConversationResul
4857
return () => { cancelled = true; };
4958
}, [agentId, isDemoMode]);
5059

51-
return { turns, isLoading, error };
60+
return { turns, totals, isLoading, error };
5261
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "marionette",
33
"private": true,
4-
"version": "1.2.5",
4+
"version": "1.2.6",
55
"packageManager": "pnpm@9.0.0",
66
"workspaces": [
77
"apps/*",

0 commit comments

Comments
 (0)