First Commmit
This commit is contained in:
706
service/ccm/service_usage.go
Normal file
706
service/ccm/service_usage.go
Normal file
@@ -0,0 +1,706 @@
|
||||
package ccm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
type UsageStats struct {
|
||||
RequestCount int `json:"request_count"`
|
||||
MessagesCount int `json:"messages_count"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
||||
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
||||
CacheCreation5MinuteInputTokens int64 `json:"cache_creation_5m_input_tokens,omitempty"`
|
||||
CacheCreation1HourInputTokens int64 `json:"cache_creation_1h_input_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type CostCombination struct {
|
||||
Model string `json:"model"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
WeekStartUnix int64 `json:"week_start_unix,omitempty"`
|
||||
Total UsageStats `json:"total"`
|
||||
ByUser map[string]UsageStats `json:"by_user"`
|
||||
}
|
||||
|
||||
type AggregatedUsage struct {
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Combinations []CostCombination `json:"combinations"`
|
||||
mutex sync.Mutex
|
||||
filePath string
|
||||
logger log.ContextLogger
|
||||
lastSaveTime time.Time
|
||||
pendingSave bool
|
||||
saveTimer *time.Timer
|
||||
saveMutex sync.Mutex
|
||||
}
|
||||
|
||||
type UsageStatsJSON struct {
|
||||
RequestCount int `json:"request_count"`
|
||||
MessagesCount int `json:"messages_count"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
||||
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
||||
CacheCreation5MinuteInputTokens int64 `json:"cache_creation_5m_input_tokens,omitempty"`
|
||||
CacheCreation1HourInputTokens int64 `json:"cache_creation_1h_input_tokens,omitempty"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
}
|
||||
|
||||
type CostCombinationJSON struct {
|
||||
Model string `json:"model"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
WeekStartUnix int64 `json:"week_start_unix,omitempty"`
|
||||
Total UsageStatsJSON `json:"total"`
|
||||
ByUser map[string]UsageStatsJSON `json:"by_user"`
|
||||
}
|
||||
|
||||
type CostsSummaryJSON struct {
|
||||
TotalUSD float64 `json:"total_usd"`
|
||||
ByUser map[string]float64 `json:"by_user"`
|
||||
ByWeek map[string]float64 `json:"by_week,omitempty"`
|
||||
ByUserAndWeek map[string]map[string]float64 `json:"by_user_and_week,omitempty"`
|
||||
}
|
||||
|
||||
type AggregatedUsageJSON struct {
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Costs CostsSummaryJSON `json:"costs"`
|
||||
Combinations []CostCombinationJSON `json:"combinations"`
|
||||
}
|
||||
|
||||
type WeeklyCycleHint struct {
|
||||
WindowMinutes int64
|
||||
ResetAt time.Time
|
||||
}
|
||||
|
||||
type ModelPricing struct {
|
||||
InputPrice float64
|
||||
OutputPrice float64
|
||||
CacheReadPrice float64
|
||||
CacheWritePrice5Minute float64
|
||||
CacheWritePrice1Hour float64
|
||||
}
|
||||
|
||||
type modelFamily struct {
|
||||
pattern *regexp.Regexp
|
||||
standardPricing ModelPricing
|
||||
premiumPricing *ModelPricing
|
||||
}
|
||||
|
||||
var (
|
||||
opus46StandardPricing = ModelPricing{
|
||||
InputPrice: 5.0,
|
||||
OutputPrice: 25.0,
|
||||
CacheReadPrice: 0.5,
|
||||
CacheWritePrice5Minute: 6.25,
|
||||
CacheWritePrice1Hour: 10.0,
|
||||
}
|
||||
|
||||
opus46PremiumPricing = ModelPricing{
|
||||
InputPrice: 10.0,
|
||||
OutputPrice: 37.5,
|
||||
CacheReadPrice: 1.0,
|
||||
CacheWritePrice5Minute: 12.5,
|
||||
CacheWritePrice1Hour: 20.0,
|
||||
}
|
||||
|
||||
opus45Pricing = ModelPricing{
|
||||
InputPrice: 5.0,
|
||||
OutputPrice: 25.0,
|
||||
CacheReadPrice: 0.5,
|
||||
CacheWritePrice5Minute: 6.25,
|
||||
CacheWritePrice1Hour: 10.0,
|
||||
}
|
||||
|
||||
opus4Pricing = ModelPricing{
|
||||
InputPrice: 15.0,
|
||||
OutputPrice: 75.0,
|
||||
CacheReadPrice: 1.5,
|
||||
CacheWritePrice5Minute: 18.75,
|
||||
CacheWritePrice1Hour: 30.0,
|
||||
}
|
||||
|
||||
sonnet46StandardPricing = ModelPricing{
|
||||
InputPrice: 3.0,
|
||||
OutputPrice: 15.0,
|
||||
CacheReadPrice: 0.3,
|
||||
CacheWritePrice5Minute: 3.75,
|
||||
CacheWritePrice1Hour: 6.0,
|
||||
}
|
||||
|
||||
sonnet46PremiumPricing = ModelPricing{
|
||||
InputPrice: 6.0,
|
||||
OutputPrice: 22.5,
|
||||
CacheReadPrice: 0.6,
|
||||
CacheWritePrice5Minute: 7.5,
|
||||
CacheWritePrice1Hour: 12.0,
|
||||
}
|
||||
|
||||
sonnet45StandardPricing = ModelPricing{
|
||||
InputPrice: 3.0,
|
||||
OutputPrice: 15.0,
|
||||
CacheReadPrice: 0.3,
|
||||
CacheWritePrice5Minute: 3.75,
|
||||
CacheWritePrice1Hour: 6.0,
|
||||
}
|
||||
|
||||
sonnet45PremiumPricing = ModelPricing{
|
||||
InputPrice: 6.0,
|
||||
OutputPrice: 22.5,
|
||||
CacheReadPrice: 0.6,
|
||||
CacheWritePrice5Minute: 7.5,
|
||||
CacheWritePrice1Hour: 12.0,
|
||||
}
|
||||
|
||||
sonnet4StandardPricing = ModelPricing{
|
||||
InputPrice: 3.0,
|
||||
OutputPrice: 15.0,
|
||||
CacheReadPrice: 0.3,
|
||||
CacheWritePrice5Minute: 3.75,
|
||||
CacheWritePrice1Hour: 6.0,
|
||||
}
|
||||
|
||||
sonnet4PremiumPricing = ModelPricing{
|
||||
InputPrice: 6.0,
|
||||
OutputPrice: 22.5,
|
||||
CacheReadPrice: 0.6,
|
||||
CacheWritePrice5Minute: 7.5,
|
||||
CacheWritePrice1Hour: 12.0,
|
||||
}
|
||||
|
||||
sonnet37Pricing = ModelPricing{
|
||||
InputPrice: 3.0,
|
||||
OutputPrice: 15.0,
|
||||
CacheReadPrice: 0.3,
|
||||
CacheWritePrice5Minute: 3.75,
|
||||
CacheWritePrice1Hour: 6.0,
|
||||
}
|
||||
|
||||
sonnet35Pricing = ModelPricing{
|
||||
InputPrice: 3.0,
|
||||
OutputPrice: 15.0,
|
||||
CacheReadPrice: 0.3,
|
||||
CacheWritePrice5Minute: 3.75,
|
||||
CacheWritePrice1Hour: 6.0,
|
||||
}
|
||||
|
||||
haiku45Pricing = ModelPricing{
|
||||
InputPrice: 1.0,
|
||||
OutputPrice: 5.0,
|
||||
CacheReadPrice: 0.1,
|
||||
CacheWritePrice5Minute: 1.25,
|
||||
CacheWritePrice1Hour: 2.0,
|
||||
}
|
||||
|
||||
haiku4Pricing = ModelPricing{
|
||||
InputPrice: 1.0,
|
||||
OutputPrice: 5.0,
|
||||
CacheReadPrice: 0.1,
|
||||
CacheWritePrice5Minute: 1.25,
|
||||
CacheWritePrice1Hour: 2.0,
|
||||
}
|
||||
|
||||
haiku35Pricing = ModelPricing{
|
||||
InputPrice: 0.8,
|
||||
OutputPrice: 4.0,
|
||||
CacheReadPrice: 0.08,
|
||||
CacheWritePrice5Minute: 1.0,
|
||||
CacheWritePrice1Hour: 1.6,
|
||||
}
|
||||
|
||||
haiku3Pricing = ModelPricing{
|
||||
InputPrice: 0.25,
|
||||
OutputPrice: 1.25,
|
||||
CacheReadPrice: 0.03,
|
||||
CacheWritePrice5Minute: 0.3,
|
||||
CacheWritePrice1Hour: 0.5,
|
||||
}
|
||||
|
||||
opus3Pricing = ModelPricing{
|
||||
InputPrice: 15.0,
|
||||
OutputPrice: 75.0,
|
||||
CacheReadPrice: 1.5,
|
||||
CacheWritePrice5Minute: 18.75,
|
||||
CacheWritePrice1Hour: 30.0,
|
||||
}
|
||||
|
||||
modelFamilies = []modelFamily{
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-opus-4-6(?:-|$)`),
|
||||
standardPricing: opus46StandardPricing,
|
||||
premiumPricing: &opus46PremiumPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-opus-4-5(?:-|$)`),
|
||||
standardPricing: opus45Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:opus-4(?:-|$)|4-opus-)`),
|
||||
standardPricing: opus4Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:opus-3(?:-|$)|3-opus-)`),
|
||||
standardPricing: opus3Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:sonnet-4-6(?:-|$)|4-6-sonnet-)`),
|
||||
standardPricing: sonnet46StandardPricing,
|
||||
premiumPricing: &sonnet46PremiumPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:sonnet-4-5(?:-|$)|4-5-sonnet-)`),
|
||||
standardPricing: sonnet45StandardPricing,
|
||||
premiumPricing: &sonnet45PremiumPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:sonnet-4(?:-|$)|4-sonnet-)`),
|
||||
standardPricing: sonnet4StandardPricing,
|
||||
premiumPricing: &sonnet4PremiumPricing,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-3-7-sonnet(?:-|$)`),
|
||||
standardPricing: sonnet37Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-3-5-sonnet(?:-|$)`),
|
||||
standardPricing: sonnet35Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-(?:haiku-4-5(?:-|$)|4-5-haiku-)`),
|
||||
standardPricing: haiku45Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-haiku-4(?:-|$)`),
|
||||
standardPricing: haiku4Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-3-5-haiku(?:-|$)`),
|
||||
standardPricing: haiku35Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
{
|
||||
pattern: regexp.MustCompile(`^claude-3-haiku(?:-|$)`),
|
||||
standardPricing: haiku3Pricing,
|
||||
premiumPricing: nil,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func getPricing(model string, contextWindow int) ModelPricing {
|
||||
isPremium := contextWindow >= contextWindowPremium
|
||||
|
||||
for _, family := range modelFamilies {
|
||||
if family.pattern.MatchString(model) {
|
||||
if isPremium && family.premiumPricing != nil {
|
||||
return *family.premiumPricing
|
||||
}
|
||||
return family.standardPricing
|
||||
}
|
||||
}
|
||||
|
||||
return sonnet4StandardPricing
|
||||
}
|
||||
|
||||
func calculateCost(stats UsageStats, model string, contextWindow int) float64 {
|
||||
pricing := getPricing(model, contextWindow)
|
||||
|
||||
cacheCreationCost := 0.0
|
||||
if stats.CacheCreation5MinuteInputTokens > 0 || stats.CacheCreation1HourInputTokens > 0 {
|
||||
cacheCreationCost = float64(stats.CacheCreation5MinuteInputTokens)*pricing.CacheWritePrice5Minute +
|
||||
float64(stats.CacheCreation1HourInputTokens)*pricing.CacheWritePrice1Hour
|
||||
} else {
|
||||
// Backward compatibility for usage files generated before TTL split tracking.
|
||||
cacheCreationCost = float64(stats.CacheCreationInputTokens) * pricing.CacheWritePrice5Minute
|
||||
}
|
||||
|
||||
cost := (float64(stats.InputTokens)*pricing.InputPrice +
|
||||
float64(stats.OutputTokens)*pricing.OutputPrice +
|
||||
float64(stats.CacheReadInputTokens)*pricing.CacheReadPrice +
|
||||
cacheCreationCost) / 1_000_000
|
||||
|
||||
return math.Round(cost*100) / 100
|
||||
}
|
||||
|
||||
func roundCost(cost float64) float64 {
|
||||
return math.Round(cost*100) / 100
|
||||
}
|
||||
|
||||
func normalizeCombinations(combinations []CostCombination) {
|
||||
for index := range combinations {
|
||||
if combinations[index].ByUser == nil {
|
||||
combinations[index].ByUser = make(map[string]UsageStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addUsageToCombinations(
|
||||
combinations *[]CostCombination,
|
||||
model string,
|
||||
contextWindow int,
|
||||
weekStartUnix int64,
|
||||
messagesCount int,
|
||||
inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens int64,
|
||||
user string,
|
||||
) {
|
||||
var matchedCombination *CostCombination
|
||||
for index := range *combinations {
|
||||
combination := &(*combinations)[index]
|
||||
if combination.Model == model && combination.ContextWindow == contextWindow && combination.WeekStartUnix == weekStartUnix {
|
||||
matchedCombination = combination
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedCombination == nil {
|
||||
newCombination := CostCombination{
|
||||
Model: model,
|
||||
ContextWindow: contextWindow,
|
||||
WeekStartUnix: weekStartUnix,
|
||||
Total: UsageStats{},
|
||||
ByUser: make(map[string]UsageStats),
|
||||
}
|
||||
*combinations = append(*combinations, newCombination)
|
||||
matchedCombination = &(*combinations)[len(*combinations)-1]
|
||||
}
|
||||
|
||||
if cacheCreationTokens == 0 {
|
||||
cacheCreationTokens = cacheCreation5MinuteTokens + cacheCreation1HourTokens
|
||||
}
|
||||
|
||||
matchedCombination.Total.RequestCount++
|
||||
matchedCombination.Total.MessagesCount += messagesCount
|
||||
matchedCombination.Total.InputTokens += inputTokens
|
||||
matchedCombination.Total.OutputTokens += outputTokens
|
||||
matchedCombination.Total.CacheReadInputTokens += cacheReadTokens
|
||||
matchedCombination.Total.CacheCreationInputTokens += cacheCreationTokens
|
||||
matchedCombination.Total.CacheCreation5MinuteInputTokens += cacheCreation5MinuteTokens
|
||||
matchedCombination.Total.CacheCreation1HourInputTokens += cacheCreation1HourTokens
|
||||
|
||||
if user != "" {
|
||||
userStats := matchedCombination.ByUser[user]
|
||||
userStats.RequestCount++
|
||||
userStats.MessagesCount += messagesCount
|
||||
userStats.InputTokens += inputTokens
|
||||
userStats.OutputTokens += outputTokens
|
||||
userStats.CacheReadInputTokens += cacheReadTokens
|
||||
userStats.CacheCreationInputTokens += cacheCreationTokens
|
||||
userStats.CacheCreation5MinuteInputTokens += cacheCreation5MinuteTokens
|
||||
userStats.CacheCreation1HourInputTokens += cacheCreation1HourTokens
|
||||
matchedCombination.ByUser[user] = userStats
|
||||
}
|
||||
}
|
||||
|
||||
func buildCombinationJSON(combinations []CostCombination, aggregateUserCosts map[string]float64) ([]CostCombinationJSON, float64) {
|
||||
result := make([]CostCombinationJSON, len(combinations))
|
||||
var totalCost float64
|
||||
|
||||
for index, combination := range combinations {
|
||||
combinationTotalCost := calculateCost(combination.Total, combination.Model, combination.ContextWindow)
|
||||
totalCost += combinationTotalCost
|
||||
|
||||
combinationJSON := CostCombinationJSON{
|
||||
Model: combination.Model,
|
||||
ContextWindow: combination.ContextWindow,
|
||||
WeekStartUnix: combination.WeekStartUnix,
|
||||
Total: UsageStatsJSON{
|
||||
RequestCount: combination.Total.RequestCount,
|
||||
MessagesCount: combination.Total.MessagesCount,
|
||||
InputTokens: combination.Total.InputTokens,
|
||||
OutputTokens: combination.Total.OutputTokens,
|
||||
CacheReadInputTokens: combination.Total.CacheReadInputTokens,
|
||||
CacheCreationInputTokens: combination.Total.CacheCreationInputTokens,
|
||||
CacheCreation5MinuteInputTokens: combination.Total.CacheCreation5MinuteInputTokens,
|
||||
CacheCreation1HourInputTokens: combination.Total.CacheCreation1HourInputTokens,
|
||||
CostUSD: combinationTotalCost,
|
||||
},
|
||||
ByUser: make(map[string]UsageStatsJSON),
|
||||
}
|
||||
|
||||
for user, userStats := range combination.ByUser {
|
||||
userCost := calculateCost(userStats, combination.Model, combination.ContextWindow)
|
||||
if aggregateUserCosts != nil {
|
||||
aggregateUserCosts[user] += userCost
|
||||
}
|
||||
|
||||
combinationJSON.ByUser[user] = UsageStatsJSON{
|
||||
RequestCount: userStats.RequestCount,
|
||||
MessagesCount: userStats.MessagesCount,
|
||||
InputTokens: userStats.InputTokens,
|
||||
OutputTokens: userStats.OutputTokens,
|
||||
CacheReadInputTokens: userStats.CacheReadInputTokens,
|
||||
CacheCreationInputTokens: userStats.CacheCreationInputTokens,
|
||||
CacheCreation5MinuteInputTokens: userStats.CacheCreation5MinuteInputTokens,
|
||||
CacheCreation1HourInputTokens: userStats.CacheCreation1HourInputTokens,
|
||||
CostUSD: userCost,
|
||||
}
|
||||
}
|
||||
|
||||
result[index] = combinationJSON
|
||||
}
|
||||
|
||||
return result, roundCost(totalCost)
|
||||
}
|
||||
|
||||
func formatUTCOffsetLabel(timestamp time.Time) string {
|
||||
_, offsetSeconds := timestamp.Zone()
|
||||
sign := "+"
|
||||
if offsetSeconds < 0 {
|
||||
sign = "-"
|
||||
offsetSeconds = -offsetSeconds
|
||||
}
|
||||
offsetHours := offsetSeconds / 3600
|
||||
offsetMinutes := (offsetSeconds % 3600) / 60
|
||||
if offsetMinutes == 0 {
|
||||
return fmt.Sprintf("UTC%s%d", sign, offsetHours)
|
||||
}
|
||||
return fmt.Sprintf("UTC%s%d:%02d", sign, offsetHours, offsetMinutes)
|
||||
}
|
||||
|
||||
func formatWeekStartKey(cycleStartAt time.Time) string {
|
||||
localCycleStart := cycleStartAt.In(time.Local)
|
||||
return fmt.Sprintf("%s %s", localCycleStart.Format("2006-01-02 15:04:05"), formatUTCOffsetLabel(localCycleStart))
|
||||
}
|
||||
|
||||
func buildByWeekCost(combinations []CostCombination) map[string]float64 {
|
||||
byWeek := make(map[string]float64)
|
||||
for _, combination := range combinations {
|
||||
if combination.WeekStartUnix <= 0 {
|
||||
continue
|
||||
}
|
||||
weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC()
|
||||
weekKey := formatWeekStartKey(weekStartAt)
|
||||
byWeek[weekKey] += calculateCost(combination.Total, combination.Model, combination.ContextWindow)
|
||||
}
|
||||
for weekKey, weekCost := range byWeek {
|
||||
byWeek[weekKey] = roundCost(weekCost)
|
||||
}
|
||||
return byWeek
|
||||
}
|
||||
|
||||
func buildByUserAndWeekCost(combinations []CostCombination) map[string]map[string]float64 {
|
||||
byUserAndWeek := make(map[string]map[string]float64)
|
||||
for _, combination := range combinations {
|
||||
if combination.WeekStartUnix <= 0 {
|
||||
continue
|
||||
}
|
||||
weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC()
|
||||
weekKey := formatWeekStartKey(weekStartAt)
|
||||
for user, userStats := range combination.ByUser {
|
||||
userWeeks, exists := byUserAndWeek[user]
|
||||
if !exists {
|
||||
userWeeks = make(map[string]float64)
|
||||
byUserAndWeek[user] = userWeeks
|
||||
}
|
||||
userWeeks[weekKey] += calculateCost(userStats, combination.Model, combination.ContextWindow)
|
||||
}
|
||||
}
|
||||
for _, weekCosts := range byUserAndWeek {
|
||||
for weekKey, cost := range weekCosts {
|
||||
weekCosts[weekKey] = roundCost(cost)
|
||||
}
|
||||
}
|
||||
return byUserAndWeek
|
||||
}
|
||||
|
||||
func deriveWeekStartUnix(cycleHint *WeeklyCycleHint) int64 {
|
||||
if cycleHint == nil || cycleHint.WindowMinutes <= 0 || cycleHint.ResetAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
windowDuration := time.Duration(cycleHint.WindowMinutes) * time.Minute
|
||||
return cycleHint.ResetAt.UTC().Add(-windowDuration).Unix()
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON {
|
||||
u.mutex.Lock()
|
||||
defer u.mutex.Unlock()
|
||||
|
||||
result := &AggregatedUsageJSON{
|
||||
LastUpdated: u.LastUpdated,
|
||||
Costs: CostsSummaryJSON{
|
||||
TotalUSD: 0,
|
||||
ByUser: make(map[string]float64),
|
||||
ByWeek: make(map[string]float64),
|
||||
},
|
||||
}
|
||||
|
||||
globalCombinationsJSON, totalCost := buildCombinationJSON(u.Combinations, result.Costs.ByUser)
|
||||
result.Combinations = globalCombinationsJSON
|
||||
result.Costs.TotalUSD = totalCost
|
||||
result.Costs.ByWeek = buildByWeekCost(u.Combinations)
|
||||
|
||||
if len(result.Costs.ByWeek) == 0 {
|
||||
result.Costs.ByWeek = nil
|
||||
}
|
||||
|
||||
result.Costs.ByUserAndWeek = buildByUserAndWeekCost(u.Combinations)
|
||||
if len(result.Costs.ByUserAndWeek) == 0 {
|
||||
result.Costs.ByUserAndWeek = nil
|
||||
}
|
||||
|
||||
for user, cost := range result.Costs.ByUser {
|
||||
result.Costs.ByUser[user] = roundCost(cost)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) Load() error {
|
||||
u.mutex.Lock()
|
||||
defer u.mutex.Unlock()
|
||||
|
||||
u.LastUpdated = time.Time{}
|
||||
u.Combinations = nil
|
||||
|
||||
data, err := os.ReadFile(u.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var temp struct {
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Combinations []CostCombination `json:"combinations"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &temp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.LastUpdated = temp.LastUpdated
|
||||
u.Combinations = temp.Combinations
|
||||
normalizeCombinations(u.Combinations)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) Save() error {
|
||||
jsonData := u.ToJSON()
|
||||
|
||||
data, err := json.MarshalIndent(jsonData, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpFile := u.filePath + ".tmp"
|
||||
err = os.WriteFile(tmpFile, data, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
err = os.Rename(tmpFile, u.filePath)
|
||||
if err == nil {
|
||||
u.saveMutex.Lock()
|
||||
u.lastSaveTime = time.Now()
|
||||
u.saveMutex.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) AddUsage(
|
||||
model string,
|
||||
contextWindow int,
|
||||
messagesCount int,
|
||||
inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens int64,
|
||||
user string,
|
||||
) error {
|
||||
return u.AddUsageWithCycleHint(model, contextWindow, messagesCount, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens, user, time.Now(), nil)
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) AddUsageWithCycleHint(
|
||||
model string,
|
||||
contextWindow int,
|
||||
messagesCount int,
|
||||
inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens int64,
|
||||
user string,
|
||||
observedAt time.Time,
|
||||
cycleHint *WeeklyCycleHint,
|
||||
) error {
|
||||
if model == "" {
|
||||
return E.New("model cannot be empty")
|
||||
}
|
||||
if contextWindow <= 0 {
|
||||
return E.New("contextWindow must be positive")
|
||||
}
|
||||
if observedAt.IsZero() {
|
||||
observedAt = time.Now()
|
||||
}
|
||||
|
||||
u.mutex.Lock()
|
||||
defer u.mutex.Unlock()
|
||||
|
||||
u.LastUpdated = observedAt
|
||||
weekStartUnix := deriveWeekStartUnix(cycleHint)
|
||||
|
||||
addUsageToCombinations(&u.Combinations, model, contextWindow, weekStartUnix, messagesCount, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, cacheCreation5MinuteTokens, cacheCreation1HourTokens, user)
|
||||
|
||||
go u.scheduleSave()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) scheduleSave() {
|
||||
const saveInterval = time.Minute
|
||||
|
||||
u.saveMutex.Lock()
|
||||
defer u.saveMutex.Unlock()
|
||||
|
||||
timeSinceLastSave := time.Since(u.lastSaveTime)
|
||||
|
||||
if timeSinceLastSave >= saveInterval {
|
||||
go u.saveAsync()
|
||||
return
|
||||
}
|
||||
|
||||
if u.pendingSave {
|
||||
return
|
||||
}
|
||||
|
||||
u.pendingSave = true
|
||||
remainingTime := saveInterval - timeSinceLastSave
|
||||
|
||||
u.saveTimer = time.AfterFunc(remainingTime, func() {
|
||||
u.saveMutex.Lock()
|
||||
u.pendingSave = false
|
||||
u.saveMutex.Unlock()
|
||||
u.saveAsync()
|
||||
})
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) saveAsync() {
|
||||
err := u.Save()
|
||||
if err != nil {
|
||||
if u.logger != nil {
|
||||
u.logger.Error("save usage statistics: ", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *AggregatedUsage) cancelPendingSave() {
|
||||
u.saveMutex.Lock()
|
||||
defer u.saveMutex.Unlock()
|
||||
|
||||
if u.saveTimer != nil {
|
||||
u.saveTimer.Stop()
|
||||
u.saveTimer = nil
|
||||
}
|
||||
u.pendingSave = false
|
||||
}
|
||||
Reference in New Issue
Block a user