diff --git a/internal/stats/aggregator.go b/internal/stats/aggregator.go index 8779c43..a2366f8 100644 --- a/internal/stats/aggregator.go +++ b/internal/stats/aggregator.go @@ -26,6 +26,7 @@ func Aggregate(records []SessionRecord, from, to time.Time) []DailyStat { Date: date, ByProject: make(map[string]float64), ByModel: make(map[string]float64), + ByProfile: make(map[string]float64), } dailyMap[date] = ds } @@ -36,6 +37,11 @@ func Aggregate(records []SessionRecord, from, to time.Time) []DailyStat { ds.OutputTokens += r.OutputTokens ds.ByProject[r.Project] += r.CostUSD ds.ByModel[r.Model] += r.CostUSD + profile := r.Profile + if profile == "" { + profile = "unknown" + } + ds.ByProfile[profile] += r.CostUSD } // Convert map to sorted slice @@ -154,6 +160,28 @@ func ModelBreakdown(stats []DailyStat) []ModelCost { return result } +// ProfileBreakdown aggregates costs by API profile across all daily stats. +// Returns a slice of ProfileCost sorted by cost descending. +func ProfileBreakdown(stats []DailyStat) []ProfileCost { + totals := make(map[string]float64) + for _, s := range stats { + for profile, cost := range s.ByProfile { + totals[profile] += cost + } + } + + result := make([]ProfileCost, 0, len(totals)) + for profile, cost := range totals { + result = append(result, ProfileCost{Profile: profile, Cost: cost}) + } + + sort.Slice(result, func(i, j int) bool { + return result[i].Cost > result[j].Cost + }) + + return result +} + // GenerateSummary creates a comprehensive summary from session records. func GenerateSummary(records []SessionRecord, from, to time.Time) Summary { dailyStats := Aggregate(records, from, to) @@ -183,6 +211,7 @@ func GenerateSummary(records []SessionRecord, from, to time.Time) Summary { CacheRead: cacheRead, TopProjects: ProjectBreakdown(dailyStats), TopModels: ModelBreakdown(dailyStats), + TopProfiles: ProfileBreakdown(dailyStats), DailyBreakdown: dailyStats, } } diff --git a/internal/stats/scanner.go b/internal/stats/scanner.go index 1ee3bea..8317373 100644 --- a/internal/stats/scanner.go +++ b/internal/stats/scanner.go @@ -91,6 +91,12 @@ func ScanSessions(opts ScanOptions) ([]SessionRecord, error) { projects = make(map[string]config.ProjectEntry) } + // Determine current default profile name + currentProfile := "unknown" + if cfg, err := config.LoadConfig(); err == nil && cfg.Default != "" { + currentProfile = cfg.Default + } + var records []SessionRecord for _, entry := range entries { @@ -126,7 +132,7 @@ func ScanSessions(opts ScanOptions) ([]SessionRecord, error) { sessionID := strings.TrimSuffix(sf.Name(), ".jsonl") filePath := filepath.Join(dirPath, sf.Name()) - record, err := parseSessionFile(filePath, sessionID, projectAlias, projectPath) + record, err := parseSessionFile(filePath, sessionID, projectAlias, projectPath, currentProfile) if err != nil { continue // skip unparseable files } @@ -140,7 +146,7 @@ func ScanSessions(opts ScanOptions) ([]SessionRecord, error) { } // parseSessionFile reads a single JSONL session file and extracts a SessionRecord. -func parseSessionFile(path, sessionID, project, projectPath string) (*SessionRecord, error) { +func parseSessionFile(path, sessionID, project, projectPath, profile string) (*SessionRecord, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open session file: %w", err) @@ -151,6 +157,7 @@ func parseSessionFile(path, sessionID, project, projectPath string) (*SessionRec SessionID: sessionID, Project: project, ProjectPath: projectPath, + Profile: profile, } var ( diff --git a/internal/stats/types.go b/internal/stats/types.go index 0b6eadf..2df3103 100644 --- a/internal/stats/types.go +++ b/internal/stats/types.go @@ -7,6 +7,7 @@ type SessionRecord struct { SessionID string `json:"sessionId"` Project string `json:"project"` // codes project alias (e.g. "codes"), falls back to path ProjectPath string `json:"projectPath"` // full filesystem path + Profile string `json:"profile"` // API profile name (config.Default at scan time), falls back to "unknown" Model string `json:"model"` StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` @@ -28,6 +29,7 @@ type DailyStat struct { OutputTokens int64 `json:"outputTokens"` ByProject map[string]float64 `json:"byProject"` // project alias -> cost ByModel map[string]float64 `json:"byModel"` // model name -> cost + ByProfile map[string]float64 `json:"byProfile"` // API profile name -> cost } // StatsCache is the on-disk cache of all scanned session data. @@ -55,6 +57,7 @@ type Summary struct { CacheRead int64 `json:"cacheReadTokens"` TopProjects []ProjectCost `json:"topProjects"` TopModels []ModelCost `json:"topModels"` + TopProfiles []ProfileCost `json:"topProfiles"` DailyBreakdown []DailyStat `json:"dailyBreakdown"` } @@ -69,3 +72,9 @@ type ModelCost struct { Model string `json:"model"` Cost float64 `json:"cost"` } + +// ProfileCost represents cost aggregation for a single API profile. +type ProfileCost struct { + Profile string `json:"profile"` + Cost float64 `json:"cost"` +} diff --git a/internal/tui/stats_view.go b/internal/tui/stats_view.go index 8202cbc..6b3557b 100644 --- a/internal/tui/stats_view.go +++ b/internal/tui/stats_view.go @@ -88,6 +88,8 @@ func (m Model) updateStats(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "project": m.statsBreakdown = "model" case "model": + m.statsBreakdown = "profile" + case "profile": m.statsBreakdown = "both" } return m, nil @@ -186,6 +188,17 @@ func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, tim } } + // By Profile breakdown + if breakdown == "both" || breakdown == "profile" { + profileCosts := aggregateByProfile(daily) + if len(profileCosts) > 0 { + b.WriteString(statsHeaderStyle.Render(" By Profile:")) + b.WriteString("\n") + renderBreakdown(&b, profileCosts, totalCost, barWidth, 8) + b.WriteString("\n") + } + } + // Daily trend chart if len(daily) > 1 { b.WriteString(statsHeaderStyle.Render(" Daily Trend:")) @@ -195,7 +208,7 @@ func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, tim // Help footer b.WriteString("\n") - breakdownLabel := map[string]string{"both": "all", "project": "project", "model": "model"}[breakdown] + breakdownLabel := map[string]string{"both": "all", "project": "project", "model": "model", "profile": "profile"}[breakdown] help := fmt.Sprintf(" w:week m:month a:all f:group(%s) r:refresh", breakdownLabel) b.WriteString(statsDimStyle.Render(help)) @@ -228,6 +241,16 @@ func aggregateByModel(daily []stats.DailyStat) []costEntry { return sortedEntries(totals) } +func aggregateByProfile(daily []stats.DailyStat) []costEntry { + totals := make(map[string]float64) + for _, d := range daily { + for profile, cost := range d.ByProfile { + totals[profile] += cost + } + } + return sortedEntries(totals) +} + func sortedEntries(m map[string]float64) []costEntry { entries := make([]costEntry, 0, len(m)) for name, cost := range m {