feat: add cost transparency for AI chat messages

Per-message cost display:
- Add model, usage, cost fields to AssistantMessage (local-only, not sent to API)
- Store cost/model/usage when saving assistant messages in session-manager
- Display subtle cost footer on assistant messages: "gpt-4o-mini • 847 tokens • $0.0012"
- Clean model names by removing provider prefixes and date suffixes

Session cost display:
- Calculate total conversation cost from messages
- Show "Session: $0.0234" above input area when cost > 0
- Updates automatically as messages are added

Cost formatting:
- Show 4 decimal places for costs < $0.01
- Show 2 decimal places for costs >= $0.01
- Show "<$0.0001" for very small costs

https://claude.ai/code/session_01HqtD9R33oqfB14Gu1V5wHC
This commit is contained in:
Claude
2026-01-31 14:53:11 +00:00
parent 32cf096c99
commit 15d353e6e6
3 changed files with 159 additions and 75 deletions

View File

@@ -6,7 +6,7 @@
* Powered by ChatSessionManager for multi-window support.
*/
import { useState, useEffect, useCallback, useRef, memo } from "react";
import { useState, useEffect, useCallback, useRef, memo, useMemo } from "react";
import {
Loader2,
PanelLeft,
@@ -17,7 +17,6 @@ import {
Brain,
Settings2,
MessageSquare,
RefreshCw,
Play,
Sparkles,
AlertCircle,
@@ -121,46 +120,58 @@ const MessageBubble = memo(function MessageBubble({
// Assistant message
const assistantMsg = message as AssistantMessage;
// Format cost info for display
const costInfo = formatMessageCost(assistantMsg);
return (
<div className="flex w-full justify-start">
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-muted text-foreground space-y-2">
{/* Reasoning content (collapsible) */}
{assistantMsg.reasoning_content && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground flex items-center gap-1.5">
<Brain className="h-3 w-3" />
<span>Reasoning</span>
</summary>
<div className="mt-2 pl-4 border-l-2 border-muted-foreground/30 text-muted-foreground whitespace-pre-wrap">
{assistantMsg.reasoning_content}
</div>
</details>
)}
{/* Tool calls */}
{assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0 && (
<div className="space-y-1">
{assistantMsg.tool_calls.map((tc) => (
<div
key={tc.id}
className="text-xs bg-background/50 rounded px-2 py-1.5 border border-border"
>
<div className="flex items-center gap-1.5 text-muted-foreground mb-1">
<Settings2 className="h-3 w-3" />
<span className="font-medium">{tc.function.name}</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[10px]">
{formatToolArgs(tc.function.arguments)}
</pre>
<div className="max-w-[85%] space-y-1">
<div className="rounded-lg px-3 py-2 text-sm bg-muted text-foreground space-y-2">
{/* Reasoning content (collapsible) */}
{assistantMsg.reasoning_content && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground flex items-center gap-1.5">
<Brain className="h-3 w-3" />
<span>Reasoning</span>
</summary>
<div className="mt-2 pl-4 border-l-2 border-muted-foreground/30 text-muted-foreground whitespace-pre-wrap">
{assistantMsg.reasoning_content}
</div>
))}
</div>
)}
</details>
)}
{/* Regular content */}
{content && (
<div className="[&>article]:p-0 [&>article]:m-0">
<MarkdownContent content={content} />
{/* Tool calls */}
{assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0 && (
<div className="space-y-1">
{assistantMsg.tool_calls.map((tc) => (
<div
key={tc.id}
className="text-xs bg-background/50 rounded px-2 py-1.5 border border-border"
>
<div className="flex items-center gap-1.5 text-muted-foreground mb-1">
<Settings2 className="h-3 w-3" />
<span className="font-medium">{tc.function.name}</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[10px]">
{formatToolArgs(tc.function.arguments)}
</pre>
</div>
))}
</div>
)}
{/* Regular content */}
{content && (
<div className="[&>article]:p-0 [&>article]:m-0">
<MarkdownContent content={content} />
</div>
)}
</div>
{/* Cost info footer */}
{costInfo && (
<div className="text-[10px] text-muted-foreground/70 px-1 flex items-center gap-1.5">
{costInfo}
</div>
)}
</div>
@@ -180,6 +191,48 @@ function formatToolArgs(args: string): string {
}
}
/**
* Format message cost info for display.
* Returns null if no cost info available.
*/
function formatMessageCost(msg: AssistantMessage): string | null {
const parts: string[] = [];
// Model name (clean it up for display)
if (msg.model) {
const modelName = msg.model
.replace(/^(openai\/|anthropic\/|google\/|meta-llama\/|mistralai\/)/, "")
.replace(/-\d{4}-\d{2}-\d{2}$/, "");
parts.push(modelName);
}
// Token count
if (msg.usage) {
const total = msg.usage.promptTokens + msg.usage.completionTokens;
parts.push(`${total.toLocaleString()} tokens`);
}
// Cost
if (msg.cost !== undefined && msg.cost > 0) {
parts.push(formatCost(msg.cost));
}
return parts.length > 0 ? parts.join(" • ") : null;
}
/**
* Format cost in USD.
*/
function formatCost(cost: number): string {
if (cost < 0.0001) {
return "<$0.0001";
}
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
return `$${cost.toFixed(2)}`;
}
// ─────────────────────────────────────────────────────────────
// Thinking Indicator
// ─────────────────────────────────────────────────────────────
@@ -273,6 +326,16 @@ function ChatPanel({
// Prompt options for selector
const promptOptions = usePromptOptions();
// Calculate total conversation cost from messages
const conversationCost = useMemo(() => {
return messages.reduce((total, msg) => {
if (msg.role === "assistant" && "cost" in msg && msg.cost) {
return total + msg.cost;
}
return total;
}, 0);
}, [messages]);
// Local UI state
const [input, setInput] = useState("");
const [pendingUserMessage, setPendingUserMessage] = useState<string | null>(
@@ -499,27 +562,37 @@ function ChatPanel({
</div>
</div>
<div className="border-t p-2 flex-shrink-0 flex justify-center">
<div className="flex gap-2 items-end w-full max-w-4xl">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="flex-1 max-h-[120px] resize-none text-sm"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<Button variant="outline" size="icon" onClick={handleStop}>
<Square className="h-4 w-4" />
</Button>
) : (
<Button size="icon" onClick={handleSend} disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
)}
<div className="border-t flex-shrink-0 flex flex-col items-center">
{/* Session cost indicator */}
{conversationCost > 0 && (
<div className="w-full max-w-4xl px-2 pt-1">
<div className="text-[10px] text-muted-foreground/60 text-right">
Session: {formatCost(conversationCost)}
</div>
</div>
)}
<div className="p-2 flex justify-center w-full">
<div className="flex gap-2 items-end w-full max-w-4xl">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="flex-1 max-h-[120px] resize-none text-sm"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<Button variant="outline" size="icon" onClick={handleStop}>
<Square className="h-4 w-4" />
</Button>
) : (
<Button size="icon" onClick={handleSend} disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</>
@@ -544,11 +617,7 @@ export function AIViewer({ subcommand }: AIViewerProps) {
const { instances, activeInstanceId, activeInstance, setActiveInstance } =
useLLMProviders();
const {
models,
loading: modelsLoading,
refresh: refreshModels,
} = useLLMModels(activeInstanceId);
const { models, loading: modelsLoading } = useLLMModels(activeInstanceId);
const { conversations } = useConversations();
const { deleteConversation } = useChatActions();

View File

@@ -679,12 +679,27 @@ class ChatSessionManager {
}
}
// Calculate cost before creating message (so we can include it)
let cost = 0;
if (usage) {
cost = await this.calculateCost(
providerInstanceId,
modelId,
usage.promptTokens,
usage.completionTokens,
);
}
// Create assistant message with all accumulated content
const assistantMessage: AssistantMessage = {
id: crypto.randomUUID(),
role: "assistant",
content: streaming.content,
timestamp: Date.now(),
// Local-only fields for cost display
model: modelId,
usage,
cost: cost > 0 ? cost : undefined,
};
// Add optional fields if present
@@ -706,17 +721,6 @@ class ChatSessionManager {
this.messageAdded$.next({ conversationId, message: assistantMessage });
// Calculate cost
let cost = 0;
if (usage) {
cost = await this.calculateCost(
providerInstanceId,
modelId,
usage.promptTokens,
usage.completionTokens,
);
}
return {
assistantMessage,
finishReason,

View File

@@ -350,6 +350,17 @@ export interface AssistantMessage extends BaseMessage {
reasoning_content?: string;
/** Tool calls requested by the assistant */
tool_calls?: ToolCall[];
// ─── Local-only fields (not sent to API) ───
/** Model that generated this response (may differ from requested due to routing) */
model?: string;
/** Token usage for this message */
usage?: {
promptTokens: number;
completionTokens: number;
};
/** Cost in USD for this message */
cost?: number;
}
/**