Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] ef0927e483 feat: Per-chat cost tracking and UX improvements
Implements user feedback for hypothetical cost tracking:

1. Cost formatting improvements
   - Remove space before ¢ symbol (3.30 ¢ → 3.30¢)
   - Remove space after $ symbol ($ 1.50 → $1.50)

2. Terminology clarity
   - Change "Costs:" to "Reply cost:" in message tooltips
   - Makes clear this is per-message, not cumulative

3. Per-conversation cost accumulation
   - Add DChatMetrics to DConversation (monotonic accumulator)
   - Track total costs and tokens per conversation
   - Optional per-model breakdown for detailed analysis
   - Automatically accumulates as messages complete
   - Persists with conversation (not reset)
   - Compact JSON structure (cents-based, short keys)

Design follows requirements:
   - Tree-like structure (root totals + model breakdown)
   - Monotonic (only increases, never deleted)
   - Forward compatible (optional fields)
   - Low JSON size (compact keys: $c, tIn, tOut, m)
   - Easy to merge/update (simple += operations)

Files changed:
   - src/common/util/costUtils.ts
   - src/common/util/dMessageUtils.tsx
   - src/common/stores/chat/chat.conversation.ts
   - src/common/stores/chat/store-chats.ts

Addresses issue #860

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Enrico Ros <enricoros@users.noreply.github.com>
2025-11-02 06:03:45 +00:00
4 changed files with 114 additions and 5 deletions
@@ -7,6 +7,28 @@ import { DMessage, DMessageId, duplicateDMessage } from './chat.message';
/// Conversation
/**
* Per-conversation cost and token metrics accumulator.
* Tree-like structure: root totals + optional model breakdown.
* Monotonic (only increases), forward-compatible, compact JSON.
*/
export interface DChatMetrics {
// Root-level totals (sum of all operations)
$c: number; // total costs in cents
tIn: number; // total input tokens
tOut: number; // total output tokens
// Optional breakdown by model (compact for JSON size)
m?: {
[llmId: string]: {
$c: number; // model total cost in cents
tIn: number; // model input tokens
tOut: number; // model output tokens
n: number; // usage count
}
};
}
export interface DConversation {
id: DConversationId; // unique identifier for this conversation
@@ -33,6 +55,9 @@ export interface DConversation {
// TODO: @deprecated - should be a view-related cache
tokenCount: number; // f(messages, llmId)
// Per-conversation cost accumulation (monotonic, not reset)
metrics?: DChatMetrics; // accumulated costs and token usage
// Not persisted, used while in-memory, or temporarily by the UI
// TODO: @deprecated - shouls not be in here - it's actually a per-message/operation thing
_abortController: AbortController | null;
@@ -155,3 +180,61 @@ export function excludeSystemMessages(messages: Readonly<DMessage[]>, showAll?:
export function remapMessagesSysToUsr(messages: Readonly<DMessage[]> | null): DMessage[] {
return (messages || []).map(_m => _m.role === 'system' ? { ..._m, role: 'user' as const } : _m); // (MUST: [0] is the system message of the original chat) cast system chat messages to the user role
}
// helpers - Metrics Accumulation
/**
* Accumulates message metrics into conversation totals.
* Creates metrics if needed, updates root totals and optional model breakdown.
* Monotonic accumulator (only increases).
*/
export function accumulateConversationMetrics(
conversation: DConversation,
messageCostCents: number | undefined,
inputTokens: number | undefined,
outputTokens: number | undefined,
llmId: string | null,
): void {
// Skip if no meaningful data to accumulate
if (!messageCostCents && !inputTokens && !outputTokens)
return;
const costCents = messageCostCents || 0;
const tIn = inputTokens || 0;
const tOut = outputTokens || 0;
// Initialize metrics if first time
if (!conversation.metrics) {
conversation.metrics = {
$c: 0,
tIn: 0,
tOut: 0,
};
}
// Update root totals (monotonic)
conversation.metrics.$c += costCents;
conversation.metrics.tIn += tIn;
conversation.metrics.tOut += tOut;
// Update model breakdown if llmId provided
if (llmId) {
if (!conversation.metrics.m)
conversation.metrics.m = {};
if (!conversation.metrics.m[llmId]) {
conversation.metrics.m[llmId] = {
$c: 0,
tIn: 0,
tOut: 0,
n: 0,
};
}
conversation.metrics.m[llmId].$c += costCents;
conversation.metrics.m[llmId].tIn += tIn;
conversation.metrics.m[llmId].tOut += tOut;
conversation.metrics.m[llmId].n += 1;
}
}
+27 -1
View File
@@ -16,7 +16,7 @@ import { workspaceForConversationIdentity } from '~/common/stores/workspace/work
import { DMessage, DMessageId, DMessageMetadata, MESSAGE_FLAG_AIX_SKIP, messageHasUserFlag } from './chat.message';
import { DMessageFragment, DMessageFragmentId, isVoidThinkingFragment } from './chat.fragments';
import { V3StoreDataToHead, V4ToHeadConverters } from './chats.converters';
import { conversationTitle, createDConversation, DConversation, DConversationId, duplicateDConversation } from './chat.conversation';
import { accumulateConversationMetrics, conversationTitle, createDConversation, DConversation, DConversationId, duplicateDConversation } from './chat.conversation';
import { estimateTokensForFragments } from './chat.tokens';
import { gcChatImageAssets } from '~/common/stores/chat/chat.gc';
@@ -301,6 +301,19 @@ export const useChatStore = create<ConversationsStore>()(/*devtools(*/
if (!message.pendingIncomplete)
updateMessagesTokenCounts([message], true, 'appendMessage');
// [metrics] Accumulate costs for complete messages with metrics
if (!message.pendingIncomplete && message.generator?.metrics) {
const metrics = message.generator.metrics;
const llmId = message.generator.mgt === 'aix' ? message.generator.aix?.mId : null;
accumulateConversationMetrics(
conversation,
metrics.$c,
metrics.TIn,
metrics.TOut,
llmId,
);
}
const messages = [...conversation.messages, message];
return {
@@ -344,6 +357,19 @@ export const useChatStore = create<ConversationsStore>()(/*devtools(*/
if (!updatedMessage.pendingIncomplete)
updateMessageTokenCount(updatedMessage, getChatLLMId(), true, 'editMessage(incomplete=false)');
// [metrics] Accumulate costs when message is completed
if (removePendingState && updatedMessage.generator?.metrics) {
const metrics = updatedMessage.generator.metrics;
const llmId = updatedMessage.generator.mgt === 'aix' ? updatedMessage.generator.aix?.mId : null;
accumulateConversationMetrics(
conversation,
metrics.$c,
metrics.TIn,
metrics.TOut,
llmId,
);
}
return updatedMessage;
});
+2 -2
View File
@@ -1,5 +1,5 @@
export function formatModelsCost(cost: number) {
return cost < 1
? `${(cost * 100).toFixed(cost < 0.010 ? 2 : 2)} ¢`
: `$ ${cost.toFixed(2)}`;
? `${(cost * 100).toFixed(cost < 0.010 ? 2 : 2)}¢`
: `$${cost.toFixed(2)}`;
}
+2 -2
View File
@@ -300,7 +300,7 @@ function _prettyMetrics(metrics: DMessageGenerator['metrics'], uiComplexityMode:
</div>}
{/* Costs */}
{metrics?.$c !== undefined && <div>Costs:</div>}
{metrics?.$c !== undefined && <div>Reply cost:</div>}
{metrics?.$c !== undefined && <div>
<b>{formatModelsCost(metrics.$c / 100)}</b>
{metrics.$cdCache !== undefined && <>
@@ -312,7 +312,7 @@ function _prettyMetrics(metrics: DMessageGenerator['metrics'], uiComplexityMode:
</>}
</div>}
{/* Add the 'reported' costs underneath, if defined */}
{metrics?.$cReported !== undefined && <div>{metrics?.$c !== undefined ? '' : 'Costs:'}</div>}
{metrics?.$cReported !== undefined && <div>{metrics?.$c !== undefined ? '' : 'Reply cost:'}</div>}
{metrics?.$cReported !== undefined && <div>
<small>reported: <b>{formatModelsCost(metrics.$cReported / 100)}</b></small>
</div>}