cc-haha/src/cost-tracker.ts
程序员阿江(Relakkes) b4142c8221 Make desktop slash commands useful outside the terminal
Desktop slash commands now separate local UI panels from CLI turn execution. The session inspector exposes status, usage, and context data from the active session, including transcript and context fallbacks, so /status, /cost, and /context can render structured desktop UI instead of raw terminal text. The inspector and help surfaces now use the existing desktop i18n catalogs for English and Chinese labels.

Constraint: Desktop read-only slash commands must not spawn duplicate CLI processes or depend on submitting a normal user turn.

Rejected: Render raw CLI command text in chat | it keeps terminal-specific layout constraints and does not fit the desktop panel UX.

Confidence: high

Scope-risk: moderate

Directive: Keep /status, /cost, and /context routed through the local inspector unless the CLI exposes a structured interactive command protocol.

Tested: cd desktop && bun run lint

Tested: cd desktop && bun test src/components/chat/composerUtils.test.ts

Tested: bun test src/server/__tests__/conversations.test.ts

Tested: cd desktop && bun run build

Tested: agent-browser smoke test on http://127.0.0.1:2024/ for /context localized inspector

Tested: git diff --check

Not-tested: Full packaged Tauri desktop build.
2026-04-28 16:38:16 +08:00

382 lines
12 KiB
TypeScript

import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import chalk from 'chalk'
import {
addToTotalCostState,
addToTotalLinesChanged,
getCostCounter,
getModelUsage,
getSdkBetas,
getSessionId,
getTokenCounter,
getTotalAPIDuration,
getTotalAPIDurationWithoutRetries,
getTotalCacheCreationInputTokens,
getTotalCacheReadInputTokens,
getTotalCostUSD,
getTotalDuration,
getTotalInputTokens,
getTotalLinesAdded,
getTotalLinesRemoved,
getTotalOutputTokens,
getTotalToolDuration,
getTotalWebSearchRequests,
getUsageForModel,
hasUnknownModelCost,
resetCostState,
resetStateForTests,
setCostStateForRestore,
setHasUnknownModelCost,
} from './bootstrap/state.js'
import type { ModelUsage } from './entrypoints/agentSdkTypes.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from './services/analytics/index.js'
import { getAdvisorUsage } from './utils/advisor.js'
import {
getCurrentProjectConfig,
saveCurrentProjectConfig,
} from './utils/config.js'
import {
getContextWindowForModel,
getModelMaxOutputTokens,
} from './utils/context.js'
import { isFastModeEnabled } from './utils/fastMode.js'
import { formatDuration, formatNumber } from './utils/format.js'
import type { FpsMetrics } from './utils/fpsTracker.js'
import { getCanonicalName } from './utils/model/model.js'
import { calculateUSDCost } from './utils/modelCost.js'
export {
getTotalCostUSD as getTotalCost,
getTotalDuration,
getTotalAPIDuration,
getTotalAPIDurationWithoutRetries,
addToTotalLinesChanged,
getTotalLinesAdded,
getTotalLinesRemoved,
getTotalInputTokens,
getTotalOutputTokens,
getTotalCacheReadInputTokens,
getTotalCacheCreationInputTokens,
getTotalWebSearchRequests,
formatCost,
hasUnknownModelCost,
resetStateForTests,
resetCostState,
setHasUnknownModelCost,
getModelUsage,
getUsageForModel,
}
export type SessionUsageSnapshot = {
totalCostUSD: number
costDisplay: string
hasUnknownModelCost: boolean
totalAPIDuration: number
totalDuration: number
totalLinesAdded: number
totalLinesRemoved: number
totalInputTokens: number
totalOutputTokens: number
totalCacheReadInputTokens: number
totalCacheCreationInputTokens: number
totalWebSearchRequests: number
models: Array<{
model: string
displayName: string
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
webSearchRequests: number
costUSD: number
costDisplay: string
contextWindow: number
maxOutputTokens: number
}>
}
type StoredCostState = {
totalCostUSD: number
totalAPIDuration: number
totalAPIDurationWithoutRetries: number
totalToolDuration: number
totalLinesAdded: number
totalLinesRemoved: number
lastDuration: number | undefined
modelUsage: { [modelName: string]: ModelUsage } | undefined
}
/**
* Gets stored cost state from project config for a specific session.
* Returns the cost data if the session ID matches, or undefined otherwise.
* Use this to read costs BEFORE overwriting the config with saveCurrentSessionCosts().
*/
export function getStoredSessionCosts(
sessionId: string,
): StoredCostState | undefined {
const projectConfig = getCurrentProjectConfig()
// Only return costs if this is the same session that was last saved
if (projectConfig.lastSessionId !== sessionId) {
return undefined
}
// Build model usage with context windows
let modelUsage: { [modelName: string]: ModelUsage } | undefined
if (projectConfig.lastModelUsage) {
modelUsage = Object.fromEntries(
Object.entries(projectConfig.lastModelUsage).map(([model, usage]) => [
model,
{
...usage,
contextWindow: getContextWindowForModel(model, getSdkBetas()),
maxOutputTokens: getModelMaxOutputTokens(model).default,
},
]),
)
}
return {
totalCostUSD: projectConfig.lastCost ?? 0,
totalAPIDuration: projectConfig.lastAPIDuration ?? 0,
totalAPIDurationWithoutRetries:
projectConfig.lastAPIDurationWithoutRetries ?? 0,
totalToolDuration: projectConfig.lastToolDuration ?? 0,
totalLinesAdded: projectConfig.lastLinesAdded ?? 0,
totalLinesRemoved: projectConfig.lastLinesRemoved ?? 0,
lastDuration: projectConfig.lastDuration,
modelUsage,
}
}
/**
* Restores cost state from project config when resuming a session.
* Only restores if the session ID matches the last saved session.
* @returns true if cost state was restored, false otherwise
*/
export function restoreCostStateForSession(sessionId: string): boolean {
const data = getStoredSessionCosts(sessionId)
if (!data) {
return false
}
setCostStateForRestore(data)
return true
}
/**
* Saves the current session's costs to project config.
* Call this before switching sessions to avoid losing accumulated costs.
*/
export function saveCurrentSessionCosts(fpsMetrics?: FpsMetrics): void {
saveCurrentProjectConfig(current => ({
...current,
lastCost: getTotalCostUSD(),
lastAPIDuration: getTotalAPIDuration(),
lastAPIDurationWithoutRetries: getTotalAPIDurationWithoutRetries(),
lastToolDuration: getTotalToolDuration(),
lastDuration: getTotalDuration(),
lastLinesAdded: getTotalLinesAdded(),
lastLinesRemoved: getTotalLinesRemoved(),
lastTotalInputTokens: getTotalInputTokens(),
lastTotalOutputTokens: getTotalOutputTokens(),
lastTotalCacheCreationInputTokens: getTotalCacheCreationInputTokens(),
lastTotalCacheReadInputTokens: getTotalCacheReadInputTokens(),
lastTotalWebSearchRequests: getTotalWebSearchRequests(),
lastFpsAverage: fpsMetrics?.averageFps,
lastFpsLow1Pct: fpsMetrics?.low1PctFps,
lastModelUsage: Object.fromEntries(
Object.entries(getModelUsage()).map(([model, usage]) => [
model,
{
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens,
webSearchRequests: usage.webSearchRequests,
costUSD: usage.costUSD,
},
]),
),
lastSessionId: getSessionId(),
}))
}
function formatCost(cost: number, maxDecimalPlaces: number = 4): string {
return `$${cost > 0.5 ? round(cost, 100).toFixed(2) : cost.toFixed(maxDecimalPlaces)}`
}
function formatModelUsage(): string {
const modelUsageMap = getModelUsage()
if (Object.keys(modelUsageMap).length === 0) {
return 'Usage: 0 input, 0 output, 0 cache read, 0 cache write'
}
// Accumulate usage by short name
const usageByShortName: { [shortName: string]: ModelUsage } = {}
for (const [model, usage] of Object.entries(modelUsageMap)) {
const shortName = getCanonicalName(model)
if (!usageByShortName[shortName]) {
usageByShortName[shortName] = {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
webSearchRequests: 0,
costUSD: 0,
contextWindow: 0,
maxOutputTokens: 0,
}
}
const accumulated = usageByShortName[shortName]
accumulated.inputTokens += usage.inputTokens
accumulated.outputTokens += usage.outputTokens
accumulated.cacheReadInputTokens += usage.cacheReadInputTokens
accumulated.cacheCreationInputTokens += usage.cacheCreationInputTokens
accumulated.webSearchRequests += usage.webSearchRequests
accumulated.costUSD += usage.costUSD
}
let result = 'Usage by model:'
for (const [shortName, usage] of Object.entries(usageByShortName)) {
const usageString =
` ${formatNumber(usage.inputTokens)} input, ` +
`${formatNumber(usage.outputTokens)} output, ` +
`${formatNumber(usage.cacheReadInputTokens)} cache read, ` +
`${formatNumber(usage.cacheCreationInputTokens)} cache write` +
(usage.webSearchRequests > 0
? `, ${formatNumber(usage.webSearchRequests)} web search`
: '') +
` (${formatCost(usage.costUSD)})`
result += `\n` + `${shortName}:`.padStart(21) + usageString
}
return result
}
export function formatTotalCost(): string {
const costDisplay =
formatCost(getTotalCostUSD()) +
(hasUnknownModelCost()
? ' (costs may be inaccurate due to usage of unknown models)'
: '')
const modelUsageDisplay = formatModelUsage()
return chalk.dim(
`Total cost: ${costDisplay}\n` +
`Total duration (API): ${formatDuration(getTotalAPIDuration())}
Total duration (wall): ${formatDuration(getTotalDuration())}
Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed
${modelUsageDisplay}`,
)
}
export function getSessionUsageSnapshot(): SessionUsageSnapshot {
return {
totalCostUSD: getTotalCostUSD(),
costDisplay: formatCost(getTotalCostUSD()),
hasUnknownModelCost: hasUnknownModelCost(),
totalAPIDuration: getTotalAPIDuration(),
totalDuration: getTotalDuration(),
totalLinesAdded: getTotalLinesAdded(),
totalLinesRemoved: getTotalLinesRemoved(),
totalInputTokens: getTotalInputTokens(),
totalOutputTokens: getTotalOutputTokens(),
totalCacheReadInputTokens: getTotalCacheReadInputTokens(),
totalCacheCreationInputTokens: getTotalCacheCreationInputTokens(),
totalWebSearchRequests: getTotalWebSearchRequests(),
models: Object.entries(getModelUsage()).map(([model, usage]) => ({
model,
displayName: getCanonicalName(model),
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens,
webSearchRequests: usage.webSearchRequests,
costUSD: usage.costUSD,
costDisplay: formatCost(usage.costUSD),
contextWindow: usage.contextWindow,
maxOutputTokens: usage.maxOutputTokens,
})),
}
}
function round(number: number, precision: number): number {
return Math.round(number * precision) / precision
}
function addToTotalModelUsage(
cost: number,
usage: Usage,
model: string,
): ModelUsage {
const modelUsage = getUsageForModel(model) ?? {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
webSearchRequests: 0,
costUSD: 0,
contextWindow: 0,
maxOutputTokens: 0,
}
modelUsage.inputTokens += usage.input_tokens
modelUsage.outputTokens += usage.output_tokens
modelUsage.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0
modelUsage.cacheCreationInputTokens += usage.cache_creation_input_tokens ?? 0
modelUsage.webSearchRequests +=
usage.server_tool_use?.web_search_requests ?? 0
modelUsage.costUSD += cost
modelUsage.contextWindow = getContextWindowForModel(model, getSdkBetas())
modelUsage.maxOutputTokens = getModelMaxOutputTokens(model).default
return modelUsage
}
export function addToTotalSessionCost(
cost: number,
usage: Usage,
model: string,
): number {
const modelUsage = addToTotalModelUsage(cost, usage, model)
addToTotalCostState(cost, modelUsage, model)
const attrs =
isFastModeEnabled() && usage.speed === 'fast'
? { model, speed: 'fast' }
: { model }
getCostCounter()?.add(cost, attrs)
getTokenCounter()?.add(usage.input_tokens, { ...attrs, type: 'input' })
getTokenCounter()?.add(usage.output_tokens, { ...attrs, type: 'output' })
getTokenCounter()?.add(usage.cache_read_input_tokens ?? 0, {
...attrs,
type: 'cacheRead',
})
getTokenCounter()?.add(usage.cache_creation_input_tokens ?? 0, {
...attrs,
type: 'cacheCreation',
})
let totalCost = cost
for (const advisorUsage of getAdvisorUsage(usage)) {
const advisorCost = calculateUSDCost(advisorUsage.model, advisorUsage)
logEvent('tengu_advisor_tool_token_usage', {
advisor_model:
advisorUsage.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
input_tokens: advisorUsage.input_tokens,
output_tokens: advisorUsage.output_tokens,
cache_read_input_tokens: advisorUsage.cache_read_input_tokens ?? 0,
cache_creation_input_tokens:
advisorUsage.cache_creation_input_tokens ?? 0,
cost_usd_micros: Math.round(advisorCost * 1_000_000),
})
totalCost += addToTotalSessionCost(
advisorCost,
advisorUsage,
advisorUsage.model,
)
}
return totalCost
}