cost-tracker.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
  2. import chalk from 'chalk'
  3. import {
  4. addToTotalCostState,
  5. addToTotalLinesChanged,
  6. getCostCounter,
  7. getModelUsage,
  8. getSdkBetas,
  9. getSessionId,
  10. getTokenCounter,
  11. getTotalAPIDuration,
  12. getTotalAPIDurationWithoutRetries,
  13. getTotalCacheCreationInputTokens,
  14. getTotalCacheReadInputTokens,
  15. getTotalCostUSD,
  16. getTotalDuration,
  17. getTotalInputTokens,
  18. getTotalLinesAdded,
  19. getTotalLinesRemoved,
  20. getTotalOutputTokens,
  21. getTotalToolDuration,
  22. getTotalWebSearchRequests,
  23. getUsageForModel,
  24. hasUnknownModelCost,
  25. resetCostState,
  26. resetStateForTests,
  27. setCostStateForRestore,
  28. setHasUnknownModelCost,
  29. } from './bootstrap/state.js'
  30. import type { ModelUsage } from './entrypoints/agentSdkTypes.js'
  31. import {
  32. type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  33. logEvent,
  34. } from './services/analytics/index.js'
  35. import { getAdvisorUsage } from './utils/advisor.js'
  36. import {
  37. getCurrentProjectConfig,
  38. saveCurrentProjectConfig,
  39. } from './utils/config.js'
  40. import {
  41. getContextWindowForModel,
  42. getModelMaxOutputTokens,
  43. } from './utils/context.js'
  44. import { isFastModeEnabled } from './utils/fastMode.js'
  45. import { formatDuration, formatNumber } from './utils/format.js'
  46. import type { FpsMetrics } from './utils/fpsTracker.js'
  47. import { getCanonicalName } from './utils/model/model.js'
  48. import { calculateUSDCost } from './utils/modelCost.js'
  49. export {
  50. getTotalCostUSD as getTotalCost,
  51. getTotalDuration,
  52. getTotalAPIDuration,
  53. getTotalAPIDurationWithoutRetries,
  54. addToTotalLinesChanged,
  55. getTotalLinesAdded,
  56. getTotalLinesRemoved,
  57. getTotalInputTokens,
  58. getTotalOutputTokens,
  59. getTotalCacheReadInputTokens,
  60. getTotalCacheCreationInputTokens,
  61. getTotalWebSearchRequests,
  62. formatCost,
  63. hasUnknownModelCost,
  64. resetStateForTests,
  65. resetCostState,
  66. setHasUnknownModelCost,
  67. getModelUsage,
  68. getUsageForModel,
  69. }
  70. type StoredCostState = {
  71. totalCostUSD: number
  72. totalAPIDuration: number
  73. totalAPIDurationWithoutRetries: number
  74. totalToolDuration: number
  75. totalLinesAdded: number
  76. totalLinesRemoved: number
  77. lastDuration: number | undefined
  78. modelUsage: { [modelName: string]: ModelUsage } | undefined
  79. }
  80. /**
  81. * Gets stored cost state from project config for a specific session.
  82. * Returns the cost data if the session ID matches, or undefined otherwise.
  83. * Use this to read costs BEFORE overwriting the config with saveCurrentSessionCosts().
  84. */
  85. export function getStoredSessionCosts(
  86. sessionId: string,
  87. ): StoredCostState | undefined {
  88. const projectConfig = getCurrentProjectConfig()
  89. // Only return costs if this is the same session that was last saved
  90. if (projectConfig.lastSessionId !== sessionId) {
  91. return undefined
  92. }
  93. // Build model usage with context windows
  94. let modelUsage: { [modelName: string]: ModelUsage } | undefined
  95. if (projectConfig.lastModelUsage) {
  96. modelUsage = Object.fromEntries(
  97. Object.entries(projectConfig.lastModelUsage).map(([model, usage]) => [
  98. model,
  99. {
  100. ...usage,
  101. contextWindow: getContextWindowForModel(model, getSdkBetas()),
  102. maxOutputTokens: getModelMaxOutputTokens(model).default,
  103. },
  104. ]),
  105. )
  106. }
  107. return {
  108. totalCostUSD: projectConfig.lastCost ?? 0,
  109. totalAPIDuration: projectConfig.lastAPIDuration ?? 0,
  110. totalAPIDurationWithoutRetries:
  111. projectConfig.lastAPIDurationWithoutRetries ?? 0,
  112. totalToolDuration: projectConfig.lastToolDuration ?? 0,
  113. totalLinesAdded: projectConfig.lastLinesAdded ?? 0,
  114. totalLinesRemoved: projectConfig.lastLinesRemoved ?? 0,
  115. lastDuration: projectConfig.lastDuration,
  116. modelUsage,
  117. }
  118. }
  119. /**
  120. * Restores cost state from project config when resuming a session.
  121. * Only restores if the session ID matches the last saved session.
  122. * @returns true if cost state was restored, false otherwise
  123. */
  124. export function restoreCostStateForSession(sessionId: string): boolean {
  125. const data = getStoredSessionCosts(sessionId)
  126. if (!data) {
  127. return false
  128. }
  129. setCostStateForRestore(data)
  130. return true
  131. }
  132. /**
  133. * Saves the current session's costs to project config.
  134. * Call this before switching sessions to avoid losing accumulated costs.
  135. */
  136. export function saveCurrentSessionCosts(fpsMetrics?: FpsMetrics): void {
  137. saveCurrentProjectConfig(current => ({
  138. ...current,
  139. lastCost: getTotalCostUSD(),
  140. lastAPIDuration: getTotalAPIDuration(),
  141. lastAPIDurationWithoutRetries: getTotalAPIDurationWithoutRetries(),
  142. lastToolDuration: getTotalToolDuration(),
  143. lastDuration: getTotalDuration(),
  144. lastLinesAdded: getTotalLinesAdded(),
  145. lastLinesRemoved: getTotalLinesRemoved(),
  146. lastTotalInputTokens: getTotalInputTokens(),
  147. lastTotalOutputTokens: getTotalOutputTokens(),
  148. lastTotalCacheCreationInputTokens: getTotalCacheCreationInputTokens(),
  149. lastTotalCacheReadInputTokens: getTotalCacheReadInputTokens(),
  150. lastTotalWebSearchRequests: getTotalWebSearchRequests(),
  151. lastFpsAverage: fpsMetrics?.averageFps,
  152. lastFpsLow1Pct: fpsMetrics?.low1PctFps,
  153. lastModelUsage: Object.fromEntries(
  154. Object.entries(getModelUsage()).map(([model, usage]) => [
  155. model,
  156. {
  157. inputTokens: usage.inputTokens,
  158. outputTokens: usage.outputTokens,
  159. cacheReadInputTokens: usage.cacheReadInputTokens,
  160. cacheCreationInputTokens: usage.cacheCreationInputTokens,
  161. webSearchRequests: usage.webSearchRequests,
  162. costUSD: usage.costUSD,
  163. },
  164. ]),
  165. ),
  166. lastSessionId: getSessionId(),
  167. }))
  168. }
  169. function formatCost(cost: number, maxDecimalPlaces: number = 4): string {
  170. return `$${cost > 0.5 ? round(cost, 100).toFixed(2) : cost.toFixed(maxDecimalPlaces)}`
  171. }
  172. function formatModelUsage(): string {
  173. const modelUsageMap = getModelUsage()
  174. if (Object.keys(modelUsageMap).length === 0) {
  175. return 'Usage: 0 input, 0 output, 0 cache read, 0 cache write'
  176. }
  177. // Accumulate usage by short name
  178. const usageByShortName: { [shortName: string]: ModelUsage } = {}
  179. for (const [model, usage] of Object.entries(modelUsageMap)) {
  180. const shortName = getCanonicalName(model)
  181. if (!usageByShortName[shortName]) {
  182. usageByShortName[shortName] = {
  183. inputTokens: 0,
  184. outputTokens: 0,
  185. cacheReadInputTokens: 0,
  186. cacheCreationInputTokens: 0,
  187. webSearchRequests: 0,
  188. costUSD: 0,
  189. contextWindow: 0,
  190. maxOutputTokens: 0,
  191. }
  192. }
  193. const accumulated = usageByShortName[shortName]
  194. accumulated.inputTokens += usage.inputTokens
  195. accumulated.outputTokens += usage.outputTokens
  196. accumulated.cacheReadInputTokens += usage.cacheReadInputTokens
  197. accumulated.cacheCreationInputTokens += usage.cacheCreationInputTokens
  198. accumulated.webSearchRequests += usage.webSearchRequests
  199. accumulated.costUSD += usage.costUSD
  200. }
  201. let result = 'Usage by model:'
  202. for (const [shortName, usage] of Object.entries(usageByShortName)) {
  203. const usageString =
  204. ` ${formatNumber(usage.inputTokens)} input, ` +
  205. `${formatNumber(usage.outputTokens)} output, ` +
  206. `${formatNumber(usage.cacheReadInputTokens)} cache read, ` +
  207. `${formatNumber(usage.cacheCreationInputTokens)} cache write` +
  208. (usage.webSearchRequests > 0
  209. ? `, ${formatNumber(usage.webSearchRequests)} web search`
  210. : '') +
  211. ` (${formatCost(usage.costUSD)})`
  212. result += `\n` + `${shortName}:`.padStart(21) + usageString
  213. }
  214. return result
  215. }
  216. export function formatTotalCost(): string {
  217. const costDisplay =
  218. formatCost(getTotalCostUSD()) +
  219. (hasUnknownModelCost()
  220. ? ' (costs may be inaccurate due to usage of unknown models)'
  221. : '')
  222. const modelUsageDisplay = formatModelUsage()
  223. return chalk.dim(
  224. `Total cost: ${costDisplay}\n` +
  225. `Total duration (API): ${formatDuration(getTotalAPIDuration())}
  226. Total duration (wall): ${formatDuration(getTotalDuration())}
  227. Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed
  228. ${modelUsageDisplay}`,
  229. )
  230. }
  231. function round(number: number, precision: number): number {
  232. return Math.round(number * precision) / precision
  233. }
  234. function addToTotalModelUsage(
  235. cost: number,
  236. usage: Usage,
  237. model: string,
  238. ): ModelUsage {
  239. const modelUsage = getUsageForModel(model) ?? {
  240. inputTokens: 0,
  241. outputTokens: 0,
  242. cacheReadInputTokens: 0,
  243. cacheCreationInputTokens: 0,
  244. webSearchRequests: 0,
  245. costUSD: 0,
  246. contextWindow: 0,
  247. maxOutputTokens: 0,
  248. }
  249. modelUsage.inputTokens += usage.input_tokens
  250. modelUsage.outputTokens += usage.output_tokens
  251. modelUsage.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0
  252. modelUsage.cacheCreationInputTokens += usage.cache_creation_input_tokens ?? 0
  253. modelUsage.webSearchRequests +=
  254. usage.server_tool_use?.web_search_requests ?? 0
  255. modelUsage.costUSD += cost
  256. modelUsage.contextWindow = getContextWindowForModel(model, getSdkBetas())
  257. modelUsage.maxOutputTokens = getModelMaxOutputTokens(model).default
  258. return modelUsage
  259. }
  260. export function addToTotalSessionCost(
  261. cost: number,
  262. usage: Usage,
  263. model: string,
  264. ): number {
  265. const modelUsage = addToTotalModelUsage(cost, usage, model)
  266. addToTotalCostState(cost, modelUsage, model)
  267. const attrs =
  268. isFastModeEnabled() && usage.speed === 'fast'
  269. ? { model, speed: 'fast' }
  270. : { model }
  271. getCostCounter()?.add(cost, attrs)
  272. getTokenCounter()?.add(usage.input_tokens, { ...attrs, type: 'input' })
  273. getTokenCounter()?.add(usage.output_tokens, { ...attrs, type: 'output' })
  274. getTokenCounter()?.add(usage.cache_read_input_tokens ?? 0, {
  275. ...attrs,
  276. type: 'cacheRead',
  277. })
  278. getTokenCounter()?.add(usage.cache_creation_input_tokens ?? 0, {
  279. ...attrs,
  280. type: 'cacheCreation',
  281. })
  282. let totalCost = cost
  283. for (const advisorUsage of getAdvisorUsage(usage)) {
  284. const advisorCost = calculateUSDCost(advisorUsage.model, advisorUsage)
  285. logEvent('tengu_advisor_tool_token_usage', {
  286. advisor_model:
  287. advisorUsage.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  288. input_tokens: advisorUsage.input_tokens,
  289. output_tokens: advisorUsage.output_tokens,
  290. cache_read_input_tokens: advisorUsage.cache_read_input_tokens ?? 0,
  291. cache_creation_input_tokens:
  292. advisorUsage.cache_creation_input_tokens ?? 0,
  293. cost_usd_micros: Math.round(advisorCost * 1_000_000),
  294. })
  295. totalCost += addToTotalSessionCost(
  296. advisorCost,
  297. advisorUsage,
  298. advisorUsage.model,
  299. )
  300. }
  301. return totalCost
  302. }