service_usage.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. package ccm
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "math"
  6. "os"
  7. "regexp"
  8. "sync"
  9. "time"
  10. "github.com/sagernet/sing-box/log"
  11. E "github.com/sagernet/sing/common/exceptions"
  12. )
  13. type UsageStats struct {
  14. RequestCount int `json:"request_count"`
  15. MessagesCount int `json:"messages_count"`
  16. InputTokens int64 `json:"input_tokens"`
  17. OutputTokens int64 `json:"output_tokens"`
  18. CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
  19. CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
  20. CacheCreation5MinuteInputTokens int64 `json:"cache_creation_5m_input_tokens,omitempty"`
  21. CacheCreation1HourInputTokens int64 `json:"cache_creation_1h_input_tokens,omitempty"`
  22. }
  23. type CostCombination struct {
  24. Model string `json:"model"`
  25. ContextWindow int `json:"context_window"`
  26. WeekStartUnix int64 `json:"week_start_unix,omitempty"`
  27. Total UsageStats `json:"total"`
  28. ByUser map[string]UsageStats `json:"by_user"`
  29. }
  30. type AggregatedUsage struct {
  31. LastUpdated time.Time `json:"last_updated"`
  32. Combinations []CostCombination `json:"combinations"`
  33. mutex sync.Mutex
  34. filePath string
  35. logger log.ContextLogger
  36. lastSaveTime time.Time
  37. pendingSave bool
  38. saveTimer *time.Timer
  39. saveMutex sync.Mutex
  40. }
  41. type UsageStatsJSON struct {
  42. RequestCount int `json:"request_count"`
  43. MessagesCount int `json:"messages_count"`
  44. InputTokens int64 `json:"input_tokens"`
  45. OutputTokens int64 `json:"output_tokens"`
  46. CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
  47. CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
  48. CacheCreation5MinuteInputTokens int64 `json:"cache_creation_5m_input_tokens,omitempty"`
  49. CacheCreation1HourInputTokens int64 `json:"cache_creation_1h_input_tokens,omitempty"`
  50. CostUSD float64 `json:"cost_usd"`
  51. }
  52. type CostCombinationJSON struct {
  53. Model string `json:"model"`
  54. ContextWindow int `json:"context_window"`
  55. WeekStartUnix int64 `json:"week_start_unix,omitempty"`
  56. Total UsageStatsJSON `json:"total"`
  57. ByUser map[string]UsageStatsJSON `json:"by_user"`
  58. }
  59. type CostsSummaryJSON struct {
  60. TotalUSD float64 `json:"total_usd"`
  61. ByUser map[string]float64 `json:"by_user"`
  62. ByWeek map[string]float64 `json:"by_week,omitempty"`
  63. ByUserAndWeek map[string]map[string]float64 `json:"by_user_and_week,omitempty"`
  64. }
  65. type AggregatedUsageJSON struct {
  66. LastUpdated time.Time `json:"last_updated"`
  67. Costs CostsSummaryJSON `json:"costs"`
  68. Combinations []CostCombinationJSON `json:"combinations"`
  69. }
  70. type WeeklyCycleHint struct {
  71. WindowMinutes int64
  72. ResetAt time.Time
  73. }
  74. type ModelPricing struct {
  75. InputPrice float64
  76. OutputPrice float64
  77. CacheReadPrice float64
  78. CacheWritePrice5Minute float64
  79. CacheWritePrice1Hour float64
  80. }
  81. type modelFamily struct {
  82. pattern *regexp.Regexp
  83. standardPricing ModelPricing
  84. premiumPricing *ModelPricing
  85. }
  86. var (
  87. opus46StandardPricing = ModelPricing{
  88. InputPrice: 5.0,
  89. OutputPrice: 25.0,
  90. CacheReadPrice: 0.5,
  91. CacheWritePrice5Minute: 6.25,
  92. CacheWritePrice1Hour: 10.0,
  93. }
  94. opus46PremiumPricing = ModelPricing{
  95. InputPrice: 10.0,
  96. OutputPrice: 37.5,
  97. CacheReadPrice: 1.0,
  98. CacheWritePrice5Minute: 12.5,
  99. CacheWritePrice1Hour: 20.0,
  100. }
  101. opus45Pricing = ModelPricing{
  102. InputPrice: 5.0,
  103. OutputPrice: 25.0,
  104. CacheReadPrice: 0.5,
  105. CacheWritePrice5Minute: 6.25,
  106. CacheWritePrice1Hour: 10.0,
  107. }
  108. opus4Pricing = ModelPricing{
  109. InputPrice: 15.0,
  110. OutputPrice: 75.0,
  111. CacheReadPrice: 1.5,
  112. CacheWritePrice5Minute: 18.75,
  113. CacheWritePrice1Hour: 30.0,
  114. }
  115. sonnet46StandardPricing = ModelPricing{
  116. InputPrice: 3.0,
  117. OutputPrice: 15.0,
  118. CacheReadPrice: 0.3,
  119. CacheWritePrice5Minute: 3.75,
  120. CacheWritePrice1Hour: 6.0,
  121. }
  122. sonnet46PremiumPricing = ModelPricing{
  123. InputPrice: 6.0,
  124. OutputPrice: 22.5,
  125. CacheReadPrice: 0.6,
  126. CacheWritePrice5Minute: 7.5,
  127. CacheWritePrice1Hour: 12.0,
  128. }
  129. sonnet45StandardPricing = ModelPricing{
  130. InputPrice: 3.0,
  131. OutputPrice: 15.0,
  132. CacheReadPrice: 0.3,
  133. CacheWritePrice5Minute: 3.75,
  134. CacheWritePrice1Hour: 6.0,
  135. }
  136. sonnet45PremiumPricing = ModelPricing{
  137. InputPrice: 6.0,
  138. OutputPrice: 22.5,
  139. CacheReadPrice: 0.6,
  140. CacheWritePrice5Minute: 7.5,
  141. CacheWritePrice1Hour: 12.0,
  142. }
  143. sonnet4StandardPricing = ModelPricing{
  144. InputPrice: 3.0,
  145. OutputPrice: 15.0,
  146. CacheReadPrice: 0.3,
  147. CacheWritePrice5Minute: 3.75,
  148. CacheWritePrice1Hour: 6.0,
  149. }
  150. sonnet4PremiumPricing = ModelPricing{
  151. InputPrice: 6.0,
  152. OutputPrice: 22.5,
  153. CacheReadPrice: 0.6,
  154. CacheWritePrice5Minute: 7.5,
  155. CacheWritePrice1Hour: 12.0,
  156. }
  157. sonnet37Pricing = ModelPricing{
  158. InputPrice: 3.0,
  159. OutputPrice: 15.0,
  160. CacheReadPrice: 0.3,
  161. CacheWritePrice5Minute: 3.75,
  162. CacheWritePrice1Hour: 6.0,
  163. }
  164. sonnet35Pricing = ModelPricing{
  165. InputPrice: 3.0,
  166. OutputPrice: 15.0,
  167. CacheReadPrice: 0.3,
  168. CacheWritePrice5Minute: 3.75,
  169. CacheWritePrice1Hour: 6.0,
  170. }
  171. haiku45Pricing = ModelPricing{
  172. InputPrice: 1.0,
  173. OutputPrice: 5.0,
  174. CacheReadPrice: 0.1,
  175. CacheWritePrice5Minute: 1.25,
  176. CacheWritePrice1Hour: 2.0,
  177. }
  178. haiku4Pricing = ModelPricing{
  179. InputPrice: 1.0,
  180. OutputPrice: 5.0,
  181. CacheReadPrice: 0.1,
  182. CacheWritePrice5Minute: 1.25,
  183. CacheWritePrice1Hour: 2.0,
  184. }
  185. haiku35Pricing = ModelPricing{
  186. InputPrice: 0.8,
  187. OutputPrice: 4.0,
  188. CacheReadPrice: 0.08,
  189. CacheWritePrice5Minute: 1.0,
  190. CacheWritePrice1Hour: 1.6,
  191. }
  192. haiku3Pricing = ModelPricing{
  193. InputPrice: 0.25,
  194. OutputPrice: 1.25,
  195. CacheReadPrice: 0.03,
  196. CacheWritePrice5Minute: 0.3,
  197. CacheWritePrice1Hour: 0.5,
  198. }
  199. opus3Pricing = ModelPricing{
  200. InputPrice: 15.0,
  201. OutputPrice: 75.0,
  202. CacheReadPrice: 1.5,
  203. CacheWritePrice5Minute: 18.75,
  204. CacheWritePrice1Hour: 30.0,
  205. }
  206. modelFamilies = []modelFamily{
  207. {
  208. pattern: regexp.MustCompile(`^claude-opus-4-6(?:-|$)`),
  209. standardPricing: opus46StandardPricing,
  210. premiumPricing: &opus46PremiumPricing,
  211. },
  212. {
  213. pattern: regexp.MustCompile(`^claude-opus-4-5(?:-|$)`),
  214. standardPricing: opus45Pricing,
  215. premiumPricing: nil,
  216. },
  217. {
  218. pattern: regexp.MustCompile(`^claude-(?:opus-4(?:-|$)|4-opus-)`),
  219. standardPricing: opus4Pricing,
  220. premiumPricing: nil,
  221. },
  222. {
  223. pattern: regexp.MustCompile(`^claude-(?:opus-3(?:-|$)|3-opus-)`),
  224. standardPricing: opus3Pricing,
  225. premiumPricing: nil,
  226. },
  227. {
  228. pattern: regexp.MustCompile(`^claude-(?:sonnet-4-6(?:-|$)|4-6-sonnet-)`),
  229. standardPricing: sonnet46StandardPricing,
  230. premiumPricing: &sonnet46PremiumPricing,
  231. },
  232. {
  233. pattern: regexp.MustCompile(`^claude-(?:sonnet-4-5(?:-|$)|4-5-sonnet-)`),
  234. standardPricing: sonnet45StandardPricing,
  235. premiumPricing: &sonnet45PremiumPricing,
  236. },
  237. {
  238. pattern: regexp.MustCompile(`^claude-(?:sonnet-4(?:-|$)|4-sonnet-)`),
  239. standardPricing: sonnet4StandardPricing,
  240. premiumPricing: &sonnet4PremiumPricing,
  241. },
  242. {
  243. pattern: regexp.MustCompile(`^claude-3-7-sonnet(?:-|$)`),
  244. standardPricing: sonnet37Pricing,
  245. premiumPricing: nil,
  246. },
  247. {
  248. pattern: regexp.MustCompile(`^claude-3-5-sonnet(?:-|$)`),
  249. standardPricing: sonnet35Pricing,
  250. premiumPricing: nil,
  251. },
  252. {
  253. pattern: regexp.MustCompile(`^claude-(?:haiku-4-5(?:-|$)|4-5-haiku-)`),
  254. standardPricing: haiku45Pricing,
  255. premiumPricing: nil,
  256. },
  257. {
  258. pattern: regexp.MustCompile(`^claude-haiku-4(?:-|$)`),
  259. standardPricing: haiku4Pricing,
  260. premiumPricing: nil,
  261. },
  262. {
  263. pattern: regexp.MustCompile(`^claude-3-5-haiku(?:-|$)`),
  264. standardPricing: haiku35Pricing,
  265. premiumPricing: nil,
  266. },
  267. {
  268. pattern: regexp.MustCompile(`^claude-3-haiku(?:-|$)`),
  269. standardPricing: haiku3Pricing,
  270. premiumPricing: nil,
  271. },
  272. }
  273. )
  274. func getPricing(model string, contextWindow int) ModelPricing {
  275. isPremium := contextWindow >= contextWindowPremium
  276. for _, family := range modelFamilies {
  277. if family.pattern.MatchString(model) {
  278. if isPremium && family.premiumPricing != nil {
  279. return *family.premiumPricing
  280. }
  281. return family.standardPricing
  282. }
  283. }
  284. return sonnet4StandardPricing
  285. }
  286. func calculateCost(stats UsageStats, model string, contextWindow int) float64 {
  287. pricing := getPricing(model, contextWindow)
  288. cacheCreationCost := 0.0
  289. if stats.CacheCreation5MinuteInputTokens > 0 || stats.CacheCreation1HourInputTokens > 0 {
  290. cacheCreationCost = float64(stats.CacheCreation5MinuteInputTokens)*pricing.CacheWritePrice5Minute +
  291. float64(stats.CacheCreation1HourInputTokens)*pricing.CacheWritePrice1Hour
  292. } else {
  293. // Backward compatibility for usage files generated before TTL split tracking.
  294. cacheCreationCost = float64(stats.CacheCreationInputTokens) * pricing.CacheWritePrice5Minute
  295. }
  296. cost := (float64(stats.InputTokens)*pricing.InputPrice +
  297. float64(stats.OutputTokens)*pricing.OutputPrice +
  298. float64(stats.CacheReadInputTokens)*pricing.CacheReadPrice +
  299. cacheCreationCost) / 1_000_000
  300. return math.Round(cost*100) / 100
  301. }
  302. func roundCost(cost float64) float64 {
  303. return math.Round(cost*100) / 100
  304. }
  305. func normalizeCombinations(combinations []CostCombination) {
  306. for index := range combinations {
  307. if combinations[index].ByUser == nil {
  308. combinations[index].ByUser = make(map[string]UsageStats)
  309. }
  310. }
  311. }
  312. func addUsageToCombinations(
  313. combinations *[]CostCombination,
  314. model string,
  315. contextWindow int,
  316. weekStartUnix int64,
  317. messagesCount int,
  318. inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens int64,
  319. user string,
  320. ) {
  321. var matchedCombination *CostCombination
  322. for index := range *combinations {
  323. combination := &(*combinations)[index]
  324. if combination.Model == model && combination.ContextWindow == contextWindow && combination.WeekStartUnix == weekStartUnix {
  325. matchedCombination = combination
  326. break
  327. }
  328. }
  329. if matchedCombination == nil {
  330. newCombination := CostCombination{
  331. Model: model,
  332. ContextWindow: contextWindow,
  333. WeekStartUnix: weekStartUnix,
  334. Total: UsageStats{},
  335. ByUser: make(map[string]UsageStats),
  336. }
  337. *combinations = append(*combinations, newCombination)
  338. matchedCombination = &(*combinations)[len(*combinations)-1]
  339. }
  340. if cacheCreationTokens == 0 {
  341. cacheCreationTokens = cacheCreation5MinuteTokens + cacheCreation1HourTokens
  342. }
  343. matchedCombination.Total.RequestCount++
  344. matchedCombination.Total.MessagesCount += messagesCount
  345. matchedCombination.Total.InputTokens += inputTokens
  346. matchedCombination.Total.OutputTokens += outputTokens
  347. matchedCombination.Total.CacheReadInputTokens += cacheReadTokens
  348. matchedCombination.Total.CacheCreationInputTokens += cacheCreationTokens
  349. matchedCombination.Total.CacheCreation5MinuteInputTokens += cacheCreation5MinuteTokens
  350. matchedCombination.Total.CacheCreation1HourInputTokens += cacheCreation1HourTokens
  351. if user != "" {
  352. userStats := matchedCombination.ByUser[user]
  353. userStats.RequestCount++
  354. userStats.MessagesCount += messagesCount
  355. userStats.InputTokens += inputTokens
  356. userStats.OutputTokens += outputTokens
  357. userStats.CacheReadInputTokens += cacheReadTokens
  358. userStats.CacheCreationInputTokens += cacheCreationTokens
  359. userStats.CacheCreation5MinuteInputTokens += cacheCreation5MinuteTokens
  360. userStats.CacheCreation1HourInputTokens += cacheCreation1HourTokens
  361. matchedCombination.ByUser[user] = userStats
  362. }
  363. }
  364. func buildCombinationJSON(combinations []CostCombination, aggregateUserCosts map[string]float64) ([]CostCombinationJSON, float64) {
  365. result := make([]CostCombinationJSON, len(combinations))
  366. var totalCost float64
  367. for index, combination := range combinations {
  368. combinationTotalCost := calculateCost(combination.Total, combination.Model, combination.ContextWindow)
  369. totalCost += combinationTotalCost
  370. combinationJSON := CostCombinationJSON{
  371. Model: combination.Model,
  372. ContextWindow: combination.ContextWindow,
  373. WeekStartUnix: combination.WeekStartUnix,
  374. Total: UsageStatsJSON{
  375. RequestCount: combination.Total.RequestCount,
  376. MessagesCount: combination.Total.MessagesCount,
  377. InputTokens: combination.Total.InputTokens,
  378. OutputTokens: combination.Total.OutputTokens,
  379. CacheReadInputTokens: combination.Total.CacheReadInputTokens,
  380. CacheCreationInputTokens: combination.Total.CacheCreationInputTokens,
  381. CacheCreation5MinuteInputTokens: combination.Total.CacheCreation5MinuteInputTokens,
  382. CacheCreation1HourInputTokens: combination.Total.CacheCreation1HourInputTokens,
  383. CostUSD: combinationTotalCost,
  384. },
  385. ByUser: make(map[string]UsageStatsJSON),
  386. }
  387. for user, userStats := range combination.ByUser {
  388. userCost := calculateCost(userStats, combination.Model, combination.ContextWindow)
  389. if aggregateUserCosts != nil {
  390. aggregateUserCosts[user] += userCost
  391. }
  392. combinationJSON.ByUser[user] = UsageStatsJSON{
  393. RequestCount: userStats.RequestCount,
  394. MessagesCount: userStats.MessagesCount,
  395. InputTokens: userStats.InputTokens,
  396. OutputTokens: userStats.OutputTokens,
  397. CacheReadInputTokens: userStats.CacheReadInputTokens,
  398. CacheCreationInputTokens: userStats.CacheCreationInputTokens,
  399. CacheCreation5MinuteInputTokens: userStats.CacheCreation5MinuteInputTokens,
  400. CacheCreation1HourInputTokens: userStats.CacheCreation1HourInputTokens,
  401. CostUSD: userCost,
  402. }
  403. }
  404. result[index] = combinationJSON
  405. }
  406. return result, roundCost(totalCost)
  407. }
  408. func formatUTCOffsetLabel(timestamp time.Time) string {
  409. _, offsetSeconds := timestamp.Zone()
  410. sign := "+"
  411. if offsetSeconds < 0 {
  412. sign = "-"
  413. offsetSeconds = -offsetSeconds
  414. }
  415. offsetHours := offsetSeconds / 3600
  416. offsetMinutes := (offsetSeconds % 3600) / 60
  417. if offsetMinutes == 0 {
  418. return fmt.Sprintf("UTC%s%d", sign, offsetHours)
  419. }
  420. return fmt.Sprintf("UTC%s%d:%02d", sign, offsetHours, offsetMinutes)
  421. }
  422. func formatWeekStartKey(cycleStartAt time.Time) string {
  423. localCycleStart := cycleStartAt.In(time.Local)
  424. return fmt.Sprintf("%s %s", localCycleStart.Format("2006-01-02 15:04:05"), formatUTCOffsetLabel(localCycleStart))
  425. }
  426. func buildByWeekCost(combinations []CostCombination) map[string]float64 {
  427. byWeek := make(map[string]float64)
  428. for _, combination := range combinations {
  429. if combination.WeekStartUnix <= 0 {
  430. continue
  431. }
  432. weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC()
  433. weekKey := formatWeekStartKey(weekStartAt)
  434. byWeek[weekKey] += calculateCost(combination.Total, combination.Model, combination.ContextWindow)
  435. }
  436. for weekKey, weekCost := range byWeek {
  437. byWeek[weekKey] = roundCost(weekCost)
  438. }
  439. return byWeek
  440. }
  441. func buildByUserAndWeekCost(combinations []CostCombination) map[string]map[string]float64 {
  442. byUserAndWeek := make(map[string]map[string]float64)
  443. for _, combination := range combinations {
  444. if combination.WeekStartUnix <= 0 {
  445. continue
  446. }
  447. weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC()
  448. weekKey := formatWeekStartKey(weekStartAt)
  449. for user, userStats := range combination.ByUser {
  450. userWeeks, exists := byUserAndWeek[user]
  451. if !exists {
  452. userWeeks = make(map[string]float64)
  453. byUserAndWeek[user] = userWeeks
  454. }
  455. userWeeks[weekKey] += calculateCost(userStats, combination.Model, combination.ContextWindow)
  456. }
  457. }
  458. for _, weekCosts := range byUserAndWeek {
  459. for weekKey, cost := range weekCosts {
  460. weekCosts[weekKey] = roundCost(cost)
  461. }
  462. }
  463. return byUserAndWeek
  464. }
  465. func deriveWeekStartUnix(cycleHint *WeeklyCycleHint) int64 {
  466. if cycleHint == nil || cycleHint.WindowMinutes <= 0 || cycleHint.ResetAt.IsZero() {
  467. return 0
  468. }
  469. windowDuration := time.Duration(cycleHint.WindowMinutes) * time.Minute
  470. return cycleHint.ResetAt.UTC().Add(-windowDuration).Unix()
  471. }
  472. func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON {
  473. u.mutex.Lock()
  474. defer u.mutex.Unlock()
  475. result := &AggregatedUsageJSON{
  476. LastUpdated: u.LastUpdated,
  477. Costs: CostsSummaryJSON{
  478. TotalUSD: 0,
  479. ByUser: make(map[string]float64),
  480. ByWeek: make(map[string]float64),
  481. },
  482. }
  483. globalCombinationsJSON, totalCost := buildCombinationJSON(u.Combinations, result.Costs.ByUser)
  484. result.Combinations = globalCombinationsJSON
  485. result.Costs.TotalUSD = totalCost
  486. result.Costs.ByWeek = buildByWeekCost(u.Combinations)
  487. if len(result.Costs.ByWeek) == 0 {
  488. result.Costs.ByWeek = nil
  489. }
  490. result.Costs.ByUserAndWeek = buildByUserAndWeekCost(u.Combinations)
  491. if len(result.Costs.ByUserAndWeek) == 0 {
  492. result.Costs.ByUserAndWeek = nil
  493. }
  494. for user, cost := range result.Costs.ByUser {
  495. result.Costs.ByUser[user] = roundCost(cost)
  496. }
  497. return result
  498. }
  499. func (u *AggregatedUsage) Load() error {
  500. u.mutex.Lock()
  501. defer u.mutex.Unlock()
  502. u.LastUpdated = time.Time{}
  503. u.Combinations = nil
  504. data, err := os.ReadFile(u.filePath)
  505. if err != nil {
  506. if os.IsNotExist(err) {
  507. return nil
  508. }
  509. return err
  510. }
  511. var temp struct {
  512. LastUpdated time.Time `json:"last_updated"`
  513. Combinations []CostCombination `json:"combinations"`
  514. }
  515. err = json.Unmarshal(data, &temp)
  516. if err != nil {
  517. return err
  518. }
  519. u.LastUpdated = temp.LastUpdated
  520. u.Combinations = temp.Combinations
  521. normalizeCombinations(u.Combinations)
  522. return nil
  523. }
  524. func (u *AggregatedUsage) Save() error {
  525. jsonData := u.ToJSON()
  526. data, err := json.MarshalIndent(jsonData, "", " ")
  527. if err != nil {
  528. return err
  529. }
  530. tmpFile := u.filePath + ".tmp"
  531. err = os.WriteFile(tmpFile, data, 0o644)
  532. if err != nil {
  533. return err
  534. }
  535. defer os.Remove(tmpFile)
  536. err = os.Rename(tmpFile, u.filePath)
  537. if err == nil {
  538. u.saveMutex.Lock()
  539. u.lastSaveTime = time.Now()
  540. u.saveMutex.Unlock()
  541. }
  542. return err
  543. }
  544. func (u *AggregatedUsage) AddUsage(
  545. model string,
  546. contextWindow int,
  547. messagesCount int,
  548. inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens int64,
  549. user string,
  550. ) error {
  551. return u.AddUsageWithCycleHint(model, contextWindow, messagesCount, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens, user, time.Now(), nil)
  552. }
  553. func (u *AggregatedUsage) AddUsageWithCycleHint(
  554. model string,
  555. contextWindow int,
  556. messagesCount int,
  557. inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens int64,
  558. user string,
  559. observedAt time.Time,
  560. cycleHint *WeeklyCycleHint,
  561. ) error {
  562. if model == "" {
  563. return E.New("model cannot be empty")
  564. }
  565. if contextWindow <= 0 {
  566. return E.New("contextWindow must be positive")
  567. }
  568. if observedAt.IsZero() {
  569. observedAt = time.Now()
  570. }
  571. u.mutex.Lock()
  572. defer u.mutex.Unlock()
  573. u.LastUpdated = observedAt
  574. weekStartUnix := deriveWeekStartUnix(cycleHint)
  575. addUsageToCombinations(&u.Combinations, model, contextWindow, weekStartUnix, messagesCount, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens, user)
  576. go u.scheduleSave()
  577. return nil
  578. }
  579. func (u *AggregatedUsage) scheduleSave() {
  580. const saveInterval = time.Minute
  581. u.saveMutex.Lock()
  582. defer u.saveMutex.Unlock()
  583. timeSinceLastSave := time.Since(u.lastSaveTime)
  584. if timeSinceLastSave >= saveInterval {
  585. go u.saveAsync()
  586. return
  587. }
  588. if u.pendingSave {
  589. return
  590. }
  591. u.pendingSave = true
  592. remainingTime := saveInterval - timeSinceLastSave
  593. u.saveTimer = time.AfterFunc(remainingTime, func() {
  594. u.saveMutex.Lock()
  595. u.pendingSave = false
  596. u.saveMutex.Unlock()
  597. u.saveAsync()
  598. })
  599. }
  600. func (u *AggregatedUsage) saveAsync() {
  601. err := u.Save()
  602. if err != nil {
  603. if u.logger != nil {
  604. u.logger.Error("save usage statistics: ", err)
  605. }
  606. }
  607. }
  608. func (u *AggregatedUsage) cancelPendingSave() {
  609. u.saveMutex.Lock()
  610. defer u.saveMutex.Unlock()
  611. if u.saveTimer != nil {
  612. u.saveTimer.Stop()
  613. u.saveTimer = nil
  614. }
  615. u.pendingSave = false
  616. }