mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #710 from rowboatlabs/show-usage
Show token usage in chats
This commit is contained in:
commit
506eaa97a2
10 changed files with 297 additions and 3 deletions
|
|
@ -114,6 +114,7 @@ import {
|
||||||
isErrorMessage,
|
isErrorMessage,
|
||||||
isToolCall,
|
isToolCall,
|
||||||
isToolGroup,
|
isToolGroup,
|
||||||
|
isTurnUsageMessage,
|
||||||
normalizeToolInput,
|
normalizeToolInput,
|
||||||
normalizeToolOutput,
|
normalizeToolOutput,
|
||||||
parseAttachedFiles,
|
parseAttachedFiles,
|
||||||
|
|
@ -133,6 +134,7 @@ import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
|
||||||
import * as analytics from '@/lib/analytics'
|
import * as analytics from '@/lib/analytics'
|
||||||
import { playAckCue } from '@/lib/call-sounds'
|
import { playAckCue } from '@/lib/call-sounds'
|
||||||
import { useTheme } from '@/contexts/theme-context'
|
import { useTheme } from '@/contexts/theme-context'
|
||||||
|
import { TokenUsageMenu } from '@/components/token-usage-menu'
|
||||||
|
|
||||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||||
type RunEventType = z.infer<typeof RunEvent>
|
type RunEventType = z.infer<typeof RunEvent>
|
||||||
|
|
@ -1596,6 +1598,7 @@ function App() {
|
||||||
runId,
|
runId,
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
|
sessionUsage: {},
|
||||||
pendingAskHumanRequests: new Map(pendingAskHumanRequests),
|
pendingAskHumanRequests: new Map(pendingAskHumanRequests),
|
||||||
allPermissionRequests: new Map(allPermissionRequests),
|
allPermissionRequests: new Map(allPermissionRequests),
|
||||||
permissionResponses: new Map(permissionResponses),
|
permissionResponses: new Map(permissionResponses),
|
||||||
|
|
@ -6021,6 +6024,19 @@ function App() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTurnUsageMessage(item)) {
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="-mt-6 flex justify-start px-1" data-message-id={item.id}>
|
||||||
|
<TokenUsageMenu
|
||||||
|
usage={item.usage}
|
||||||
|
scope="turn"
|
||||||
|
modelCallCount={item.modelCallCount}
|
||||||
|
align="start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isErrorMessage(item)) {
|
if (isErrorMessage(item)) {
|
||||||
if (matchBillingError(item.message)) {
|
if (matchBillingError(item.message)) {
|
||||||
return null
|
return null
|
||||||
|
|
@ -6044,6 +6060,7 @@ function App() {
|
||||||
? { runId, ...sessionChat.chatState }
|
? { runId, ...sessionChat.chatState }
|
||||||
: {
|
: {
|
||||||
runId,
|
runId,
|
||||||
|
sessionUsage: {},
|
||||||
conversation: sessionLoadErrorItems.length > 0 ? sessionLoadErrorItems : conversation,
|
conversation: sessionLoadErrorItems.length > 0 ? sessionLoadErrorItems : conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
|
|
@ -6206,6 +6223,7 @@ function App() {
|
||||||
onNewChatTab={handleNewChatTab}
|
onNewChatTab={handleNewChatTab}
|
||||||
recentRuns={runs}
|
recentRuns={runs}
|
||||||
activeRunId={runId}
|
activeRunId={runId}
|
||||||
|
sessionUsage={activeChatTabState.sessionUsage}
|
||||||
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||||
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||||
/>
|
/>
|
||||||
|
|
@ -6929,6 +6947,7 @@ function App() {
|
||||||
onOpenFullScreen={toggleRightPaneMaximize}
|
onOpenFullScreen={toggleRightPaneMaximize}
|
||||||
conversation={activeChatTabState.conversation}
|
conversation={activeChatTabState.conversation}
|
||||||
currentAssistantMessage={activeChatTabState.currentAssistantMessage}
|
currentAssistantMessage={activeChatTabState.currentAssistantMessage}
|
||||||
|
sessionUsage={activeChatTabState.sessionUsage}
|
||||||
chatTabStates={chatTabStatesForRender}
|
chatTabStates={chatTabStatesForRender}
|
||||||
viewportAnchors={chatViewportAnchorByTab}
|
viewportAnchors={chatViewportAnchorByTab}
|
||||||
isProcessing={activeIsProcessing}
|
isProcessing={activeIsProcessing}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { ArrowUpRight, ChevronDown, MessageSquare, Plus } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { TokenUsageMenu } from '@/components/token-usage-menu'
|
||||||
|
import type { TokenUsage } from '@/lib/chat-conversation'
|
||||||
|
import { hasTokenUsage } from '@/lib/token-usage'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -24,6 +27,7 @@ export interface ChatHeaderProps {
|
||||||
onNewChatTab: () => void
|
onNewChatTab: () => void
|
||||||
recentRuns?: ChatHeaderRecentRun[]
|
recentRuns?: ChatHeaderRecentRun[]
|
||||||
activeRunId?: string | null
|
activeRunId?: string | null
|
||||||
|
sessionUsage?: TokenUsage
|
||||||
onSelectRun?: (runId: string) => void
|
onSelectRun?: (runId: string) => void
|
||||||
onOpenChatHistory?: () => void
|
onOpenChatHistory?: () => void
|
||||||
}
|
}
|
||||||
|
|
@ -40,10 +44,12 @@ export function ChatHeader({
|
||||||
onNewChatTab,
|
onNewChatTab,
|
||||||
recentRuns = [],
|
recentRuns = [],
|
||||||
activeRunId,
|
activeRunId,
|
||||||
|
sessionUsage,
|
||||||
onSelectRun,
|
onSelectRun,
|
||||||
onOpenChatHistory,
|
onOpenChatHistory,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory)
|
const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory)
|
||||||
|
const showUsage = hasTokenUsage(sessionUsage)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -95,6 +101,14 @@ export function ChatHeader({
|
||||||
<span className="truncate">{activeTitle}</span>
|
<span className="truncate">{activeTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showUsage && (
|
||||||
|
<TokenUsageMenu
|
||||||
|
usage={sessionUsage}
|
||||||
|
scope="session"
|
||||||
|
className="titlebar-no-drag my-1 shrink-0"
|
||||||
|
align="end"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import {
|
||||||
type ChatTabViewState,
|
type ChatTabViewState,
|
||||||
type ConversationItem,
|
type ConversationItem,
|
||||||
type PermissionResponse,
|
type PermissionResponse,
|
||||||
|
type TokenUsage,
|
||||||
createEmptyChatTabViewState,
|
createEmptyChatTabViewState,
|
||||||
getWebSearchCardData,
|
getWebSearchCardData,
|
||||||
getComposioConnectCardData,
|
getComposioConnectCardData,
|
||||||
|
|
@ -57,12 +58,14 @@ import {
|
||||||
isErrorMessage,
|
isErrorMessage,
|
||||||
isToolCall,
|
isToolCall,
|
||||||
isToolGroup,
|
isToolGroup,
|
||||||
|
isTurnUsageMessage,
|
||||||
normalizeToolInput,
|
normalizeToolInput,
|
||||||
normalizeToolOutput,
|
normalizeToolOutput,
|
||||||
parseAttachedFiles,
|
parseAttachedFiles,
|
||||||
toToolState,
|
toToolState,
|
||||||
} from '@/lib/chat-conversation'
|
} from '@/lib/chat-conversation'
|
||||||
import { matchBillingError } from '@/lib/billing-error'
|
import { matchBillingError } from '@/lib/billing-error'
|
||||||
|
import { TokenUsageMenu } from '@/components/token-usage-menu'
|
||||||
|
|
||||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||||
|
|
||||||
|
|
@ -140,6 +143,7 @@ interface ChatSidebarProps {
|
||||||
onOpenFullScreen?: () => void
|
onOpenFullScreen?: () => void
|
||||||
conversation: ConversationItem[]
|
conversation: ConversationItem[]
|
||||||
currentAssistantMessage: string
|
currentAssistantMessage: string
|
||||||
|
sessionUsage?: TokenUsage
|
||||||
chatTabStates?: Record<string, ChatTabViewState>
|
chatTabStates?: Record<string, ChatTabViewState>
|
||||||
viewportAnchors?: Record<string, ChatViewportAnchorState>
|
viewportAnchors?: Record<string, ChatViewportAnchorState>
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
|
|
@ -210,6 +214,7 @@ export function ChatSidebar({
|
||||||
onOpenFullScreen,
|
onOpenFullScreen,
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
|
sessionUsage = {},
|
||||||
chatTabStates = {},
|
chatTabStates = {},
|
||||||
viewportAnchors = {},
|
viewportAnchors = {},
|
||||||
isProcessing,
|
isProcessing,
|
||||||
|
|
@ -350,6 +355,7 @@ export function ChatSidebar({
|
||||||
runId: runId ?? null,
|
runId: runId ?? null,
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
|
sessionUsage,
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
|
@ -358,6 +364,7 @@ export function ChatSidebar({
|
||||||
runId,
|
runId,
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
|
sessionUsage,
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
|
@ -508,6 +515,19 @@ export function ChatSidebar({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTurnUsageMessage(item)) {
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="-mt-6 flex justify-start px-1" data-message-id={item.id}>
|
||||||
|
<TokenUsageMenu
|
||||||
|
usage={item.usage}
|
||||||
|
scope="turn"
|
||||||
|
modelCallCount={item.modelCallCount}
|
||||||
|
align="start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isErrorMessage(item)) {
|
if (isErrorMessage(item)) {
|
||||||
if (matchBillingError(item.message)) {
|
if (matchBillingError(item.message)) {
|
||||||
return null
|
return null
|
||||||
|
|
@ -600,6 +620,7 @@ export function ChatSidebar({
|
||||||
onNewChatTab={onNewChatTab}
|
onNewChatTab={onNewChatTab}
|
||||||
recentRuns={recentRuns}
|
recentRuns={recentRuns}
|
||||||
activeRunId={runId}
|
activeRunId={runId}
|
||||||
|
sessionUsage={activeTabState.sessionUsage}
|
||||||
onSelectRun={onSelectRun}
|
onSelectRun={onSelectRun}
|
||||||
onOpenChatHistory={onOpenChatHistory}
|
onOpenChatHistory={onOpenChatHistory}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ import {
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
isErrorMessage,
|
isErrorMessage,
|
||||||
isToolCall,
|
isToolCall,
|
||||||
|
isTurnUsageMessage,
|
||||||
getToolDisplayName,
|
getToolDisplayName,
|
||||||
getToolErrorText,
|
getToolErrorText,
|
||||||
toToolState,
|
toToolState,
|
||||||
normalizeToolOutput,
|
normalizeToolOutput,
|
||||||
} from '@/lib/chat-conversation'
|
} from '@/lib/chat-conversation'
|
||||||
import { Tool, ToolHeader, ToolContent, ToolTabbedContent } from '@/components/ai-elements/tool'
|
import { Tool, ToolHeader, ToolContent, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||||
|
import { TokenUsageMenu } from '@/components/token-usage-menu'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact rendering of a run's conversation log — used by the live-note panel's
|
* Compact rendering of a run's conversation log — used by the live-note panel's
|
||||||
|
|
@ -34,6 +36,19 @@ export function CompactConversation({ items }: { items: ConversationItem[] }) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isTurnUsageMessage(item)) {
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="flex justify-start px-1">
|
||||||
|
<TokenUsageMenu
|
||||||
|
usage={item.usage}
|
||||||
|
scope="turn"
|
||||||
|
modelCallCount={item.modelCallCount}
|
||||||
|
className="size-5 border-transparent bg-transparent hover:bg-transparent"
|
||||||
|
align="start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (isToolCall(item)) return <CompactToolRow key={item.id} tool={item} />
|
if (isToolCall(item)) return <CompactToolRow key={item.id} tool={item} />
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
const isUser = item.role === 'user'
|
const isUser = item.role === 'user'
|
||||||
|
|
|
||||||
96
apps/x/apps/renderer/src/components/token-usage-menu.tsx
Normal file
96
apps/x/apps/renderer/src/components/token-usage-menu.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { BarChart3, MoreHorizontal } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { TokenUsage } from '@/lib/chat-conversation'
|
||||||
|
import { formatTokenCount, totalTokensOf } from '@/lib/token-usage'
|
||||||
|
|
||||||
|
type TokenUsageMenuProps = {
|
||||||
|
usage: TokenUsage
|
||||||
|
scope: 'turn' | 'session'
|
||||||
|
modelCallCount?: number
|
||||||
|
className?: string
|
||||||
|
align?: 'start' | 'center' | 'end'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenUsageMenu({
|
||||||
|
usage,
|
||||||
|
scope,
|
||||||
|
modelCallCount,
|
||||||
|
className,
|
||||||
|
align = 'center',
|
||||||
|
}: TokenUsageMenuProps) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const total = totalTokensOf(usage)
|
||||||
|
const totalText = `${formatTokenCount(total)} tokens`
|
||||||
|
const title = `Token usage for this ${scope}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex size-6 items-center justify-center rounded-md border border-border/60 bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={`${title} options`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-3.5" strokeWidth={1.8} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={align} className="w-44">
|
||||||
|
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
|
||||||
|
<BarChart3 className="size-4" />
|
||||||
|
View token usage
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{totalText}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<UsageRow label="Input tokens" value={usage.inputTokens} />
|
||||||
|
<UsageRow label="Output tokens" value={usage.outputTokens} />
|
||||||
|
<UsageRow label="Cached input tokens" value={usage.cachedInputTokens} />
|
||||||
|
<UsageRow label="Reasoning tokens" value={usage.reasoningTokens} />
|
||||||
|
{modelCallCount !== undefined && modelCallCount > 0 && (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">Model calls</span>
|
||||||
|
<span className="tabular-nums">{modelCallCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageRow({ label, value }: { label: string; value: number | undefined }) {
|
||||||
|
if (!value) return null
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="tabular-nums">{formatTokenCount(value)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,14 @@ import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRe
|
||||||
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
|
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
|
||||||
import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js'
|
import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js'
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
inputTokens?: number
|
||||||
|
outputTokens?: number
|
||||||
|
totalTokens?: number
|
||||||
|
reasoningTokens?: number
|
||||||
|
cachedInputTokens?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageAttachment {
|
export interface MessageAttachment {
|
||||||
path: string
|
path: string
|
||||||
filename: string
|
filename: string
|
||||||
|
|
@ -45,13 +53,22 @@ export interface ErrorMessage {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage
|
export interface TurnUsageMessage {
|
||||||
|
id: string
|
||||||
|
kind: 'turn-usage'
|
||||||
|
usage: TokenUsage
|
||||||
|
modelCallCount: number
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage | TurnUsageMessage
|
||||||
export type PermissionResponse = 'approve' | 'deny'
|
export type PermissionResponse = 'approve' | 'deny'
|
||||||
|
|
||||||
export type ChatTabViewState = {
|
export type ChatTabViewState = {
|
||||||
runId: string | null
|
runId: string | null
|
||||||
conversation: ConversationItem[]
|
conversation: ConversationItem[]
|
||||||
currentAssistantMessage: string
|
currentAssistantMessage: string
|
||||||
|
sessionUsage: TokenUsage
|
||||||
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||||
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||||
permissionResponses: Map<string, PermissionResponse>
|
permissionResponses: Map<string, PermissionResponse>
|
||||||
|
|
@ -67,6 +84,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
||||||
runId: null,
|
runId: null,
|
||||||
conversation: [],
|
conversation: [],
|
||||||
currentAssistantMessage: '',
|
currentAssistantMessage: '',
|
||||||
|
sessionUsage: {},
|
||||||
pendingAskHumanRequests: new Map(),
|
pendingAskHumanRequests: new Map(),
|
||||||
allPermissionRequests: new Map(),
|
allPermissionRequests: new Map(),
|
||||||
permissionResponses: new Map(),
|
permissionResponses: new Map(),
|
||||||
|
|
@ -79,6 +97,8 @@ export const isChatMessage = (item: ConversationItem): item is ChatMessage => 'r
|
||||||
export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
|
export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
|
||||||
export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
|
export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
|
||||||
'kind' in item && item.kind === 'error'
|
'kind' in item && item.kind === 'error'
|
||||||
|
export const isTurnUsageMessage = (item: ConversationItem): item is TurnUsageMessage =>
|
||||||
|
'kind' in item && item.kind === 'turn-usage'
|
||||||
|
|
||||||
export const toToolState = (status: ToolCall['status']): ToolState => {
|
export const toToolState = (status: ToolCall['status']): ToolState => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export function completed(
|
||||||
turnId: string,
|
turnId: string,
|
||||||
index: number,
|
index: number,
|
||||||
message: { role: 'assistant'; content: unknown },
|
message: { role: 'assistant'; content: unknown },
|
||||||
|
usage: Extract<TEvent, { type: 'model_call_completed' }>['usage'] = {},
|
||||||
): TEvent {
|
): TEvent {
|
||||||
return {
|
return {
|
||||||
type: 'model_call_completed',
|
type: 'model_call_completed',
|
||||||
|
|
@ -75,7 +76,7 @@ export function completed(
|
||||||
modelCallIndex: index,
|
modelCallIndex: index,
|
||||||
message: message as never,
|
message: message as never,
|
||||||
finishReason: 'stop',
|
finishReason: 'stop',
|
||||||
usage: {},
|
usage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { reduceTurn } from '@x/shared/src/turns.js'
|
import { reduceTurn } from '@x/shared/src/turns.js'
|
||||||
import { isChatMessage, isErrorMessage, isToolCall } from '@/lib/chat-conversation'
|
import { isChatMessage, isErrorMessage, isToolCall, isTurnUsageMessage } from '@/lib/chat-conversation'
|
||||||
import {
|
import {
|
||||||
applyOverlay,
|
applyOverlay,
|
||||||
buildSessionChatState,
|
buildSessionChatState,
|
||||||
|
|
@ -179,6 +179,34 @@ describe('buildTurnConversation', () => {
|
||||||
expect(tool?.input).toEqual({ x: 1 })
|
expect(tool?.input).toEqual({ x: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('adds one turn usage row from accumulated model-call usage', () => {
|
||||||
|
const state = reduceTurn([
|
||||||
|
created(T1, S1, user('run it')),
|
||||||
|
requested(T1, 0),
|
||||||
|
completed(T1, 0, assistantText('checking'), {
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
}),
|
||||||
|
requested(T1, 1, ['assistant:0']),
|
||||||
|
completed(T1, 1, assistantText('done'), {
|
||||||
|
inputTokens: 12,
|
||||||
|
outputTokens: 3,
|
||||||
|
totalTokens: 15,
|
||||||
|
cachedInputTokens: 4,
|
||||||
|
}),
|
||||||
|
turnCompleted(T1, 'done'),
|
||||||
|
])
|
||||||
|
const usage = buildTurnConversation(state).find(isTurnUsageMessage)
|
||||||
|
expect(usage?.usage).toEqual({
|
||||||
|
inputTokens: 22,
|
||||||
|
outputTokens: 8,
|
||||||
|
totalTokens: 30,
|
||||||
|
cachedInputTokens: 4,
|
||||||
|
})
|
||||||
|
expect(usage?.modelCallCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('marks permission-pending tools pending and running tools running', () => {
|
it('marks permission-pending tools pending and running tools running', () => {
|
||||||
const state = reduceTurn([
|
const state = reduceTurn([
|
||||||
created(T1, S1),
|
created(T1, S1),
|
||||||
|
|
@ -388,6 +416,37 @@ describe('buildSessionChatState', () => {
|
||||||
expect(state.isWaitingOnHuman).toBe(false)
|
expect(state.isWaitingOnHuman).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('aggregates usage across session turns', () => {
|
||||||
|
const first = reduceTurn([
|
||||||
|
created('turn-1', S1, user('first?')),
|
||||||
|
requested('turn-1', 0),
|
||||||
|
completed('turn-1', 0, assistantText('first answer'), {
|
||||||
|
inputTokens: 20,
|
||||||
|
outputTokens: 5,
|
||||||
|
totalTokens: 25,
|
||||||
|
}),
|
||||||
|
turnCompleted('turn-1', 'first answer'),
|
||||||
|
])
|
||||||
|
const second = reduceTurn([
|
||||||
|
created('turn-2', S1, user('second?')),
|
||||||
|
requested('turn-2', 0),
|
||||||
|
completed('turn-2', 0, assistantText('second answer'), {
|
||||||
|
inputTokens: 30,
|
||||||
|
outputTokens: 7,
|
||||||
|
totalTokens: 37,
|
||||||
|
reasoningTokens: 2,
|
||||||
|
}),
|
||||||
|
turnCompleted('turn-2', 'second answer'),
|
||||||
|
])
|
||||||
|
const state = buildSessionChatState([first, second], emptyOverlay())
|
||||||
|
expect(state.sessionUsage).toEqual({
|
||||||
|
inputTokens: 50,
|
||||||
|
outputTokens: 12,
|
||||||
|
totalTokens: 62,
|
||||||
|
reasoningTokens: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('is settled (not processing) when the latest turn is terminal', () => {
|
it('is settled (not processing) when the latest turn is terminal', () => {
|
||||||
const turn = reduceTurn(completedTurnLog(T1, S1, 'q', 'a'))
|
const turn = reduceTurn(completedTurnLog(T1, S1, 'q', 'a'))
|
||||||
const state = buildSessionChatState([turn], emptyOverlay())
|
const state = buildSessionChatState([turn], emptyOverlay())
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,10 @@ import type {
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
MessageAttachment,
|
MessageAttachment,
|
||||||
PermissionResponse,
|
PermissionResponse,
|
||||||
|
TokenUsage,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
} from '@/lib/chat-conversation'
|
} from '@/lib/chat-conversation'
|
||||||
|
import { addTokenUsage, hasTokenUsage } from '@/lib/token-usage'
|
||||||
|
|
||||||
// Pure derivations from reduced turn state (+ the ephemeral live overlay) to
|
// Pure derivations from reduced turn state (+ the ephemeral live overlay) to
|
||||||
// the view shapes the existing chat components already consume. No IPC, no
|
// the view shapes the existing chat components already consume. No IPC, no
|
||||||
|
|
@ -337,6 +339,16 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] {
|
||||||
} satisfies ErrorMessage)
|
} satisfies ErrorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasTokenUsage(state.usage)) {
|
||||||
|
items.push({
|
||||||
|
id: `${turnId}:usage`,
|
||||||
|
kind: 'turn-usage',
|
||||||
|
usage: state.usage,
|
||||||
|
modelCallCount: state.modelCalls.filter((call) => call.usage !== undefined).length,
|
||||||
|
timestamp: ts(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,6 +361,7 @@ type PermMeta = z.infer<typeof ToolPermissionMetadata>
|
||||||
export type SessionChatState = {
|
export type SessionChatState = {
|
||||||
conversation: ConversationItem[]
|
conversation: ConversationItem[]
|
||||||
currentAssistantMessage: string
|
currentAssistantMessage: string
|
||||||
|
sessionUsage: TokenUsage
|
||||||
// See LiveOverlay.voiceSegments.
|
// See LiveOverlay.voiceSegments.
|
||||||
voiceSegments: string[]
|
voiceSegments: string[]
|
||||||
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||||
|
|
@ -410,8 +423,10 @@ export function buildSessionChatState(
|
||||||
overlay: LiveOverlay,
|
overlay: LiveOverlay,
|
||||||
): SessionChatState {
|
): SessionChatState {
|
||||||
const conversation: ConversationItem[] = []
|
const conversation: ConversationItem[] = []
|
||||||
|
let sessionUsage: TokenUsage = {}
|
||||||
for (const turn of turns) {
|
for (const turn of turns) {
|
||||||
conversation.push(...buildTurnConversation(turn))
|
conversation.push(...buildTurnConversation(turn))
|
||||||
|
sessionUsage = addTokenUsage(sessionUsage, turn.usage)
|
||||||
}
|
}
|
||||||
for (let i = 0; i < conversation.length; i++) {
|
for (let i = 0; i < conversation.length; i++) {
|
||||||
const item = conversation[i]
|
const item = conversation[i]
|
||||||
|
|
@ -482,6 +497,7 @@ export function buildSessionChatState(
|
||||||
return {
|
return {
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage: stripVoiceTags(overlay.text),
|
currentAssistantMessage: stripVoiceTags(overlay.text),
|
||||||
|
sessionUsage,
|
||||||
voiceSegments: overlay.voiceSegments,
|
voiceSegments: overlay.voiceSegments,
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
|
|
|
||||||
33
apps/x/apps/renderer/src/lib/token-usage.ts
Normal file
33
apps/x/apps/renderer/src/lib/token-usage.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { TokenUsage } from '@/lib/chat-conversation'
|
||||||
|
|
||||||
|
const USAGE_KEYS = [
|
||||||
|
'inputTokens',
|
||||||
|
'outputTokens',
|
||||||
|
'totalTokens',
|
||||||
|
'reasoningTokens',
|
||||||
|
'cachedInputTokens',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function addTokenUsage(total: TokenUsage, usage: TokenUsage): TokenUsage {
|
||||||
|
const next = { ...total }
|
||||||
|
for (const key of USAGE_KEYS) {
|
||||||
|
const value = usage[key]
|
||||||
|
if (value !== undefined) {
|
||||||
|
next[key] = (next[key] ?? 0) + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTokenUsage(usage: TokenUsage | undefined): usage is TokenUsage {
|
||||||
|
return !!usage && USAGE_KEYS.some((key) => (usage[key] ?? 0) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalTokensOf(usage: TokenUsage): number {
|
||||||
|
return usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokenCount(tokens: number | undefined): string {
|
||||||
|
if (tokens === undefined) return '-'
|
||||||
|
return new Intl.NumberFormat('en-US', { notation: 'compact' }).format(tokens)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue