diff --git a/cmd/root/root.go b/cmd/root/root.go index 1ff4f1ef46..983847e520 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -177,6 +177,7 @@ We collect anonymous usage data to help improve docker agent. To disable: newNewCmd(), newGettingStartedCmd(), newEvalCmd(), + newUsageCmd(), newShareCmd(), newModelsCmd(), newToolsetsCmd(), diff --git a/cmd/root/usage.go b/cmd/root/usage.go new file mode 100644 index 0000000000..2af6011efc --- /dev/null +++ b/cmd/root/usage.go @@ -0,0 +1,323 @@ +package root + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "slices" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + pathx "github.com/docker/docker-agent/pkg/path" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/session/sqlitestore" + "github.com/docker/docker-agent/pkg/usage" +) + +type usageFlags struct { + sessionDB string + sessionID string + since time.Duration + asJSON bool +} + +func newUsageCmd() *cobra.Command { + var flags usageFlags + + cmd := &cobra.Command{ + Use: "usage", + Short: "Report token and cost usage from recorded sessions", + GroupID: "advanced", + Args: cobra.NoArgs, + Long: `Report what recorded sessions spent, broken down by session, model, and tool. + +Cost comes from each session's own recorded counter; this command never re-prices +anything. Token figures are summed from per-message usage, which is where the +cached-input breakdown lives, so prompt-caching wins are visible. + +A session that moved tokens but recorded no cost means the model was missing from +the pricing catalogue. Those are flagged rather than reported as $0.00, because a +report that quietly understates spend is worse than no report.`, + Example: ` docker agent usage + docker agent usage --since 24h + docker agent usage --session + docker agent usage --json | jq '.cost'`, + RunE: flags.run, + } + + cmd.Flags().StringVarP(&flags.sessionDB, "session-db", "s", "", "Path to the session database (default: /session.db)") + cmd.Flags().StringVar(&flags.sessionID, "session", "", "Report only this session ID") + cmd.Flags().DurationVar(&flags.since, "since", 0, "Report only sessions created within this duration (e.g. 24h)") + cmd.Flags().BoolVar(&flags.asJSON, "json", false, "Emit the report as JSON") + + return cmd +} + +func (f *usageFlags) run(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + dbPath, err := pathx.ExpandHomeDir(sessionDBPath(f.sessionDB)) + if err != nil { + return err + } + + store, err := sqlitestore.New(ctx, dbPath) + if err != nil { + return fmt.Errorf("opening session store: %w", err) + } + defer func() { + if err := store.Close(); err != nil { + slog.ErrorContext(ctx, "Failed to close session store", "error", err) + } + }() + + sessions, err := loadUsageSessions(ctx, store, f.sessionID, f.since, time.Now()) + if err != nil { + return err + } + + return renderUsage(cmd.OutOrStdout(), usage.Aggregate(sessions), f.asJSON) +} + +// loadUsageSessions fetches either one session or every session in the window. +// +// The listing is filtered on metadata first and only the survivors are loaded +// with their items. GetSessions pulls every message of every session into +// memory, which on a large store is gigabytes — far too much for a report that +// may be printing three rows, and for the CI use case --json exists for. +func loadUsageSessions(ctx context.Context, store session.Store, sessionRef string, + since time.Duration, now time.Time, +) ([]*session.Session, error) { + if sessionRef != "" { + id, err := resolveSessionRef(ctx, store, sessionRef) + if err != nil { + return nil, err + } + s, err := store.GetSession(ctx, id) + if err != nil { + return nil, fmt.Errorf("reading session %q: %w", sessionRef, err) + } + return []*session.Session{s}, nil + } + + summaries, err := store.GetSessionSummaries(ctx) + if err != nil { + return nil, fmt.Errorf("listing sessions: %w", err) + } + + sessions := make([]*session.Session, 0, len(summaries)) + for _, summary := range keepSummariesSince(summaries, since, now) { + s, err := store.GetSession(ctx, summary.ID) + if err != nil { + return nil, fmt.Errorf("reading session %q: %w", summary.ID, err) + } + sessions = append(sessions, s) + } + return sessions, nil +} + +// keepSummariesSince drops sessions created before now-since. A non-positive +// since keeps everything, and a session with a zero CreatedAt is kept rather +// than silently dropped — an unknown timestamp is not evidence of age. +func keepSummariesSince(summaries []session.Summary, since time.Duration, now time.Time) []session.Summary { + if since <= 0 { + return summaries + } + cutoff := now.Add(-since) + return slices.DeleteFunc(slices.Clone(summaries), func(s session.Summary) bool { + return !s.CreatedAt.IsZero() && s.CreatedAt.Before(cutoff) + }) +} + +// resolveSessionRef turns a user-supplied reference into a session ID, +// accepting the same relative forms as the rest of the CLI (-1 for the most +// recent) plus any unambiguous ID prefix. +// +// The prefix form exists because the report prints shortened IDs: rejecting the +// very string it just displayed makes --session unusable without a separate +// lookup, and there is no `sessions ls` to do that lookup with. +func resolveSessionRef(ctx context.Context, store session.Store, ref string) (string, error) { + id, err := session.ResolveSessionID(ctx, store, ref) + if err != nil { + return "", err + } + if _, err := store.GetSession(ctx, id); err == nil { + return id, nil + } + + summaries, err := store.GetSessionSummaries(ctx) + if err != nil { + return "", fmt.Errorf("listing sessions: %w", err) + } + + var matches []string + for _, summary := range summaries { + if strings.HasPrefix(summary.ID, id) { + matches = append(matches, summary.ID) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return "", fmt.Errorf("no session matches %q", ref) + default: + return "", fmt.Errorf("%q matches %d sessions; use more characters", ref, len(matches)) + } +} + +// renderUsage writes the report as JSON or as aligned text. +func renderUsage(w io.Writer, report usage.Report, asJSON bool) error { + if asJSON { + // Emit [] rather than null for the collections: `jq '.sessions[]'` on a + // quiet day should yield nothing, not an error. + if report.Sessions == nil { + report.Sessions = []usage.SessionRow{} + } + if report.Models == nil { + report.Models = []usage.ModelRow{} + } + if report.Tools == nil { + report.Tools = []usage.ToolRow{} + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(report) + } + + if len(report.Sessions) == 0 { + _, err := fmt.Fprintln(w, "No sessions recorded.") + return err + } + + tw := tabwriter.NewWriter(w, 0, 8, 2, ' ', 0) + + fmt.Fprintln(tw, "SESSION\tCREATED\tINPUT\tCACHED\tOUTPUT\tCOST\tTITLE") + for _, s := range report.Sessions { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + shortSessionID(s.ID), + formatUsageTime(s.CreatedAt), + formatTokens(s.Tokens.Input), + formatTokens(s.Tokens.CachedInput), + formatTokens(s.Tokens.Output), + formatUsageCost(s.Cost, s.CostIncomplete), + truncateTitle(s.Title, 40), + ) + } + fmt.Fprintf(tw, "\t\t\t\t\t\t\n") + fmt.Fprintf(tw, "%d session(s)\t\t%s\t%s\t%s\t%s\t\n", + len(report.Sessions), + formatTokens(report.Tokens.Input), + formatTokens(report.Tokens.CachedInput), + formatTokens(report.Tokens.Output), + formatUsageCost(report.Cost, len(report.UnpricedModels) > 0), + ) + + if len(report.Models) > 0 { + fmt.Fprintln(tw, "\t\t\t\t\t\t") + fmt.Fprintln(tw, "MODEL\tCALLS\tINPUT\tCACHED\tOUTPUT\tCOST\t") + for _, m := range report.Models { + fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%s\t%s\t\n", + m.Model, m.Calls, + formatTokens(m.Tokens.Input), + formatTokens(m.Tokens.CachedInput), + formatTokens(m.Tokens.Output), + formatUsageCost(m.Cost, m.Tokens.AnySpend() && m.Cost == 0), + ) + } + } + + if len(report.Tools) > 0 { + fmt.Fprintln(tw, "\t\t\t\t\t\t") + fmt.Fprintln(tw, "TOOL\tCALLS\t\t\t\t\t") + for _, t := range report.Tools { + fmt.Fprintf(tw, "%s\t%d\t\t\t\t\t\n", t.Tool, t.Calls) + } + } + + if err := tw.Flush(); err != nil { + return err + } + + if len(report.UnpricedModels) > 0 { + fmt.Fprintf(w, "\n! Cost is understated: no pricing for %s\n", + strings.Join(report.UnpricedModels, ", ")) + } + if unmetered := unmeteredModels(report.Models); len(unmetered) > 0 { + fmt.Fprintf(w, "\n! Token counts are understated: no usage reported for some calls to %s\n", + strings.Join(unmetered, ", ")) + } + return nil +} + +// unmeteredModels names the models that served at least one call the provider +// reported no usage for. Their token columns are short for a reason the table +// itself cannot show. +func unmeteredModels(models []usage.ModelRow) []string { + var out []string + for _, m := range models { + if m.Unmetered > 0 { + out = append(out, m.Model) + } + } + return out +} + +// formatUsageCost renders a cost, marking the ones known to be incomplete with a +// trailing "+" so an understated figure is never mistaken for the real one. +func formatUsageCost(cost float64, incomplete bool) string { + s := fmt.Sprintf("$%.4f", cost) + if cost >= 0.01 { + s = fmt.Sprintf("$%.2f", cost) + } + if incomplete { + s += "+" + } + return s +} + +// formatTokens abbreviates large counts (1.2K, 3.4M) so columns stay narrow. +func formatTokens(n int64) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%.1fK", float64(n)/1_000) + default: + return strconv.FormatInt(n, 10) + } +} + +func formatUsageTime(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Local().Format("2006-01-02 15:04") +} + +func shortSessionID(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} + +// truncateTitle bounds a title to max display runes, so a long title cannot +// break the column layout. Rune-based to avoid splitting a multi-byte character. +func truncateTitle(title string, maxRunes int) string { + title = strings.ReplaceAll(title, "\n", " ") + runes := []rune(title) + if len(runes) <= maxRunes { + return title + } + if maxRunes <= 1 { + return "…" + } + return string(runes[:maxRunes-1]) + "…" +} diff --git a/cmd/root/usage_test.go b/cmd/root/usage_test.go new file mode 100644 index 0000000000..fa5065b30a --- /dev/null +++ b/cmd/root/usage_test.go @@ -0,0 +1,334 @@ +package root + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/session/sqlitestore" + "github.com/docker/docker-agent/pkg/usage" +) + +func TestKeepSummariesSince(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + summaries := []session.Summary{ + {ID: "recent", CreatedAt: now.Add(-1 * time.Hour)}, + {ID: "old", CreatedAt: now.Add(-72 * time.Hour)}, + {ID: "undated"}, // zero CreatedAt + } + + t.Run("zero since keeps everything", func(t *testing.T) { + t.Parallel() + assert.Len(t, keepSummariesSince(summaries, 0, now), 3) + }) + + t.Run("drops sessions older than the cutoff", func(t *testing.T) { + t.Parallel() + got := keepSummariesSince(summaries, 24*time.Hour, now) + ids := make([]string, 0, len(got)) + for _, s := range got { + ids = append(ids, s.ID) + } + // An unknown timestamp is not evidence of age, so "undated" is kept. + assert.ElementsMatch(t, []string{"recent", "undated"}, ids) + }) + + t.Run("does not mutate the caller's slice", func(t *testing.T) { + t.Parallel() + input := []session.Summary{ + {ID: "a", CreatedAt: now.Add(-72 * time.Hour)}, + {ID: "b", CreatedAt: now}, + } + _ = keepSummariesSince(input, time.Hour, now) + require.Len(t, input, 2) + assert.Equal(t, "a", input[0].ID, "filtering must not reorder or clobber the input") + assert.Equal(t, "b", input[1].ID) + }) +} + +func TestRenderUsage_Text(t *testing.T) { + t.Parallel() + + report := usage.Report{ + Sessions: []usage.SessionRow{{ + ID: "abcdef0123456789", + Title: "fix the bug", + CreatedAt: time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC), + Models: []string{"openai/gpt-5"}, + Tokens: usage.Tokens{Input: 1500, CachedInput: 1200, Output: 340}, + Cost: 0.25, + }}, + Models: []usage.ModelRow{{ + Model: "openai/gpt-5", Calls: 3, + Tokens: usage.Tokens{Input: 1500, CachedInput: 1200, Output: 340}, + }}, + Tools: []usage.ToolRow{{Tool: "read_file", Calls: 7}}, + Tokens: usage.Tokens{Input: 1500, CachedInput: 1200, Output: 340}, + Cost: 0.25, + } + + var buf bytes.Buffer + require.NoError(t, renderUsage(&buf, report, false)) + out := buf.String() + + assert.Contains(t, out, "abcdef01", "session ID is shortened") + assert.Contains(t, out, "fix the bug") + assert.Contains(t, out, "1.5K", "token counts are abbreviated") + assert.Contains(t, out, "$0.25") + assert.Contains(t, out, "openai/gpt-5") + assert.Contains(t, out, "read_file") + assert.Contains(t, out, "1 session(s)") + assert.NotContains(t, out, "understated", "no unpriced models, so no warning") +} + +// A model with no price records $0 cost against real tokens. The report must say +// so rather than presenting an understated total as fact. +func TestRenderUsage_WarnsAboutUnpricedModels(t *testing.T) { + t.Parallel() + + report := usage.Report{ + Sessions: []usage.SessionRow{{ + ID: "s1", + CreatedAt: time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC), + Tokens: usage.Tokens{Input: 40, Output: 10}, + Cost: 0, + CostIncomplete: true, + }}, + Tokens: usage.Tokens{Input: 40, Output: 10}, + UnpricedModels: []string{"test/fake-root"}, + } + + var buf bytes.Buffer + require.NoError(t, renderUsage(&buf, report, false)) + out := buf.String() + + assert.Contains(t, out, "understated") + assert.Contains(t, out, "test/fake-root") + assert.Contains(t, out, "$0.0000+", "an incomplete cost is marked so it isn't read as exact") +} + +func TestRenderUsage_EmptyReport(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + require.NoError(t, renderUsage(&buf, usage.Report{}, false)) + assert.Contains(t, buf.String(), "No sessions recorded.") +} + +func TestRenderUsage_JSONIsMachineReadable(t *testing.T) { + t.Parallel() + + report := usage.Report{ + Sessions: []usage.SessionRow{{ID: "s1", Tokens: usage.Tokens{Input: 10, Output: 2}, Cost: 1.5}}, + Tokens: usage.Tokens{Input: 10, Output: 2}, + Cost: 1.5, + } + + var buf bytes.Buffer + require.NoError(t, renderUsage(&buf, report, true)) + + var round usage.Report + require.NoError(t, json.Unmarshal(buf.Bytes(), &round), "output must be valid JSON") + assert.InDelta(t, 1.5, round.Cost, 1e-9) + require.Len(t, round.Sessions, 1) + assert.Equal(t, "s1", round.Sessions[0].ID) + assert.Equal(t, int64(10), round.Sessions[0].Tokens.Input) +} + +// `jq '.sessions[]'` on an empty report should yield nothing, not error on null. +func TestRenderUsage_JSONEmitsEmptyArraysNotNull(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + require.NoError(t, renderUsage(&buf, usage.Report{}, true)) + + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(buf.Bytes(), &raw)) + for _, key := range []string{"sessions", "models", "tools"} { + assert.JSONEqf(t, "[]", string(raw[key]), "%s must be an empty array, not null", key) + } +} + +func TestFormatTokens(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + in int64 + want string + }{ + {0, "0"}, + {999, "999"}, + {1000, "1.0K"}, + {1500, "1.5K"}, + {999_999, "1000.0K"}, + {1_000_000, "1.0M"}, + {2_500_000, "2.5M"}, + } { + assert.Equalf(t, tc.want, formatTokens(tc.in), "formatTokens(%d)", tc.in) + } +} + +func TestTruncateTitle(t *testing.T) { + t.Parallel() + + assert.Equal(t, "short", truncateTitle("short", 10)) + assert.Equal(t, "abcd…", truncateTitle("abcdefgh", 5)) + assert.Equal(t, "one two", truncateTitle("one\ntwo", 10), "newlines must not break the table") + + // Rune-safe: must not split a multi-byte character. + got := truncateTitle(strings.Repeat("é", 10), 5) + assert.Equal(t, "éééé…", got) + assert.Len(t, []rune(got), 5) +} + +// A model whose provider reported no usage shows real calls against short token +// columns. Without a note that reads as "41 calls, 0 tokens, free", which is the +// same silent-understatement trap as an unpriced cost. +func TestRenderUsage_WarnsAboutUnmeteredCalls(t *testing.T) { + t.Parallel() + + report := usage.Report{ + Sessions: []usage.SessionRow{{ + ID: "s1", + CreatedAt: time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC), + Tokens: usage.Tokens{Input: 100, Output: 10}, + Cost: 0.2, + }}, + Models: []usage.ModelRow{ + {Model: "openai/gpt-5", Calls: 3, Unmetered: 2, Tokens: usage.Tokens{Input: 100, Output: 10}}, + {Model: "anthropic/claude-opus-5", Calls: 1, Tokens: usage.Tokens{Input: 10}}, + }, + Tokens: usage.Tokens{Input: 110, Output: 10}, + Cost: 0.2, + } + + var buf bytes.Buffer + require.NoError(t, renderUsage(&buf, report, false)) + out := buf.String() + + assert.Contains(t, out, "Token counts are understated") + assert.Contains(t, out, "openai/gpt-5") + assert.NotContains(t, out, "no usage reported for some calls to anthropic/claude-opus-5", + "a fully metered model must not be named") +} + +func TestRenderUsage_NoUnmeteredNoteWhenAllCallsMetered(t *testing.T) { + t.Parallel() + + report := usage.Report{ + Sessions: []usage.SessionRow{{ID: "s1", Tokens: usage.Tokens{Input: 10, Output: 2}, Cost: 1}}, + Models: []usage.ModelRow{{Model: "openai/gpt-5", Calls: 1, Tokens: usage.Tokens{Input: 10, Output: 2}}}, + Tokens: usage.Tokens{Input: 10, Output: 2}, + Cost: 1, + } + + var buf bytes.Buffer + require.NoError(t, renderUsage(&buf, report, false)) + assert.NotContains(t, buf.String(), "understated") +} + +// storeFixture builds a real SQLite store containing a delegating session, so +// the loading path is exercised end to end rather than through fixtures. Both +// blocking defects in the first round of this feature — dropped sub-session +// spend and cost read from the legacy field — were invisible to fixture tests +// and only showed up against a real store. +func storeFixture(t *testing.T) (session.Store, *session.Session) { + t.Helper() + + store, err := sqlitestore.New(t.Context(), filepath.Join(t.TempDir(), "s.db")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + + msg := func(model string, in, out int64, cost float64) session.Item { + return session.Item{Message: &session.Message{ + AgentName: "root", + Message: chat.Message{ + Role: chat.MessageRoleAssistant, + Model: model, + Usage: &chat.Usage{InputTokens: in, OutputTokens: out}, + Cost: cost, + }, + }} + } + + sub := &session.Session{ID: "sub-1", Messages: []session.Item{msg("openai/gpt-5", 900, 90, 0.90)}} + root := &session.Session{ + ID: "abcdef0123456789", + Title: "delegating run", + CreatedAt: time.Now().Add(-time.Hour), + Messages: []session.Item{ + msg("anthropic/claude-opus-5", 100, 10, 0.10), + session.NewSubSessionItem(sub), + }, + } + require.NoError(t, store.AddSession(t.Context(), root)) + + return store, root +} + +func TestLoadUsageSessions_CountsSubSessionSpend(t *testing.T) { + t.Parallel() + + store, root := storeFixture(t) + + sessions, err := loadUsageSessions(t.Context(), store, "", 0, time.Now()) + require.NoError(t, err) + require.Len(t, sessions, 1, "the listing is root-only, so sub-sessions arrive nested") + + report := usage.Aggregate(sessions) + require.Len(t, report.Sessions, 1) + assert.Equal(t, int64(1000), report.Sessions[0].Tokens.Input, + "the sub-agent's tokens must be counted") + assert.InDelta(t, root.TotalCost(), report.Sessions[0].Cost, 1e-9) + assert.False(t, report.Sessions[0].CostIncomplete, + "a priced run must not be flagged as having no pricing") +} + +// The table prints a shortened ID, so --session must accept it. +func TestLoadUsageSessions_AcceptsAnIDPrefix(t *testing.T) { + t.Parallel() + + store, root := storeFixture(t) + + sessions, err := loadUsageSessions(t.Context(), store, shortSessionID(root.ID), 0, time.Now()) + require.NoError(t, err) + require.Len(t, sessions, 1) + assert.Equal(t, root.ID, sessions[0].ID) + + // A full ID still works. + sessions, err = loadUsageSessions(t.Context(), store, root.ID, 0, time.Now()) + require.NoError(t, err) + require.Len(t, sessions, 1) +} + +func TestLoadUsageSessions_UnknownSessionIsAnError(t *testing.T) { + t.Parallel() + + store, _ := storeFixture(t) + + _, err := loadUsageSessions(t.Context(), store, "nosuchsession", 0, time.Now()) + require.Error(t, err) + assert.Contains(t, err.Error(), "no session matches") +} + +func TestLoadUsageSessions_SinceFiltersBeforeLoading(t *testing.T) { + t.Parallel() + + store, _ := storeFixture(t) + + sessions, err := loadUsageSessions(t.Context(), store, "", time.Minute, time.Now()) + require.NoError(t, err) + assert.Empty(t, sessions, "a session older than the window must not be loaded") + + sessions, err = loadUsageSessions(t.Context(), store, "", 24*time.Hour, time.Now()) + require.NoError(t, err) + assert.Len(t, sessions, 1) +} diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index 23cac6ecfe..341e19cb8e 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -447,6 +447,44 @@ $ docker agent share pull docker.io/username/my-agent:latest --force See [Agent Distribution](../../concepts/distribution/index.md) for full registry workflow details. +### `docker agent usage` + +Report what recorded sessions spent, broken down by session, model, and tool. +Token and cost figures are otherwise reachable only from the TUI's cost dialog, +so this is how a headless run — `--exec` in CI, the API server, MCP/A2A — gets +at them. + +```bash +$ docker agent usage [flags] +``` + +```console +$ docker agent usage --since 24h +SESSION CREATED INPUT CACHED OUTPUT COST TITLE +a1b2c3d4 2026-08-06 12:04 128.4K 96.2K 4.1K $0.42 fix the failing cache test +3 session(s) 412.7K 310.1K 11.9K $1.28 + +MODEL CALLS INPUT CACHED OUTPUT COST +anthropic/claude-opus-5 41 380.2K 295.0K 10.4K $1.28 + +TOOL CALLS +read_file 58 +shell 21 +``` + +| Flag | Default | Description | +| ------------------- | ------------------------------------ | ------------------------------------------------------------------ | +| `-s, --session-db` | `/session.db` | Path to the session database | +| `--session ` | (all) | Report only this session; accepts a full ID, a unique ID prefix, or a relative ref such as `-1` | +| `--since ` | (all) | Report only sessions created within this duration (e.g. `24h`) | +| `--json` | `false` | Emit the report as JSON, for CI | + +Spend from delegated sub-agents is counted into the session that started them, +matching the TUI. Cost is what the runtime recorded as it ran — nothing is +re-priced — so a session that moved tokens but recorded no cost means the model +was missing from the pricing catalogue. Those are flagged with a trailing `+` +and named at the end of the report rather than shown as a plain `$0.00`. + ### `docker agent eval` Run agent evaluations against a directory of recorded sessions. diff --git a/pkg/usage/usage.go b/pkg/usage/usage.go new file mode 100644 index 0000000000..f44c6ef468 --- /dev/null +++ b/pkg/usage/usage.go @@ -0,0 +1,281 @@ +// Package usage aggregates token and cost figures out of persisted sessions. +// +// It exists so the numbers the TUI shows in its cost dialog are also reachable +// from a headless run: `docker agent usage` reads the session store and reports +// what was spent, per session, per model, and per tool. +// +// # What the numbers mean +// +// Cost is summed from the per-message cost the runtime recorded as it ran — this +// package never re-prices anything. Token figures come from the same per-message +// usage, which is also the only place the cached-input and cache-write breakdown +// is persisted. Both are read from the same items, so a per-model cost breakdown +// falls out for free. +// +// Cost and tokens are both read per message, so they agree with each other and +// with what the TUI's cost dialog shows. Tokens with no cost means the model was +// missing from the pricing catalogue, and is surfaced as +// [SessionRow.CostIncomplete] and [Report.UnpricedModels] rather than being +// silently reported as $0.00. +// +// # Sub-sessions +// +// Delegated work lives in sub-sessions, and in a multi-agent run that is where +// most of the spend is. Aggregation recurses into them, matching +// [session.Session.TotalCost] and the TUI. A sub-agent's tokens, cost, model +// calls and tool calls all land in the parent session's row, since that is the +// unit a user starts and pays for. +package usage + +import ( + "cmp" + "slices" + "time" + + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/session" +) + +// Tokens is a token breakdown. CachedInput and CacheWrite are reported +// separately from Input so the effect of prompt caching is visible. +type Tokens struct { + Input int64 `json:"input"` + CachedInput int64 `json:"cached_input"` + CacheWrite int64 `json:"cache_write"` + Output int64 `json:"output"` + Reasoning int64 `json:"reasoning"` +} + +// Total is the headline "tokens moved" figure: input plus output. CachedInput is +// already a subset of Input, and CacheWrite/Reasoning are reported on their own +// rather than folded in, so adding them would double-count. +// +// Total is for display. Use [Tokens.AnySpend] to ask whether anything was +// consumed at all — a run can burn reasoning tokens without moving a single +// input or output token, and Total would report 0 for it. +func (t Tokens) Total() int64 { return t.Input + t.Output } + +// AnySpend reports whether any tokens at all were consumed, including the kinds +// [Tokens.Total] deliberately leaves out. It is what "did this cost anything?" +// should be keyed on. +func (t Tokens) AnySpend() bool { + return t.Input > 0 || t.CachedInput > 0 || t.CacheWrite > 0 || t.Output > 0 || t.Reasoning > 0 +} + +// addUsage accumulates u into dst. A free function rather than a method so +// Tokens keeps value receivers throughout (it is embedded in JSON-serialized +// rows, where a mixed receiver set is a trap). A nil u is a no-op so callers can +// pass an optional usage without checking. +func addUsage(dst *Tokens, u *chat.Usage) { + if u == nil { + return + } + dst.Input += u.InputTokens + dst.CachedInput += u.CachedInputTokens + dst.CacheWrite += u.CacheWriteTokens + dst.Output += u.OutputTokens + dst.Reasoning += u.ReasoningTokens +} + +// SessionRow is one session's spend. +type SessionRow struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + CreatedAt time.Time `json:"created_at"` + // Models used in the session, sorted and de-duplicated. + Models []string `json:"models,omitempty"` + Tokens Tokens `json:"tokens"` + Cost float64 `json:"cost"` + // CostIncomplete marks a session that moved tokens but recorded no cost, + // i.e. at least one model was missing from the pricing catalogue. + CostIncomplete bool `json:"cost_incomplete,omitempty"` +} + +// ModelRow is one model's token spend across every session in the report. +// +// Calls counts model responses, not tool calls, and counts a response whether or +// not the provider reported usage for it: the call happened either way, and +// dropping it would hide a model that a usage-tracking-disabled provider served +// entirely. Unmetered is how many of those calls carried no usage, so short +// token columns are explained rather than mysterious. +type ModelRow struct { + Model string `json:"model"` + Calls int `json:"calls"` + Unmetered int `json:"unmetered_calls,omitempty"` + Tokens Tokens `json:"tokens"` + Cost float64 `json:"cost"` +} + +// ToolRow is how often a tool was called across every session in the report. +type ToolRow struct { + Tool string `json:"tool"` + Calls int `json:"calls"` +} + +// Report is the aggregate view. Sessions are newest first; Models and Tools are +// ordered by size descending so the cost drivers come first. +type Report struct { + Sessions []SessionRow `json:"sessions"` + Models []ModelRow `json:"models"` + Tools []ToolRow `json:"tools"` + Tokens Tokens `json:"tokens"` + Cost float64 `json:"cost"` + // UnpricedModels lists models that moved tokens in a session that recorded + // no cost. Sorted. + UnpricedModels []string `json:"unpriced_models,omitempty"` +} + +// Aggregate builds a [Report] from the supplied sessions. Nil entries are +// skipped, so a caller can pass a store listing straight through. +func Aggregate(sessions []*session.Session) Report { + var report Report + + models := map[string]*ModelRow{} + toolCalls := map[string]int{} + unpriced := map[string]struct{}{} + + for _, s := range sessions { + if s == nil { + continue + } + + row := SessionRow{ID: s.ID, Title: s.Title, CreatedAt: s.CreatedAt} + sessionModels := map[string]struct{}{} + + walkSession(s, &row, sessionModels, models, toolCalls) + + row.Models = sortedKeys(sessionModels) + + // Tokens consumed but nothing charged: the model is unpriced. Keyed on + // AnySpend, not Total, so a reasoning-only run is not silently reported + // as a genuine $0.00. + if row.Tokens.AnySpend() && row.Cost == 0 { + row.CostIncomplete = true + for _, m := range row.Models { + unpriced[m] = struct{}{} + } + } + + report.Sessions = append(report.Sessions, row) + report.Cost += row.Cost + report.Tokens.Input += row.Tokens.Input + report.Tokens.CachedInput += row.Tokens.CachedInput + report.Tokens.CacheWrite += row.Tokens.CacheWrite + report.Tokens.Output += row.Tokens.Output + report.Tokens.Reasoning += row.Tokens.Reasoning + } + + slices.SortFunc(report.Sessions, func(a, b SessionRow) int { + if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 { + return c + } + return cmp.Compare(a.ID, b.ID) + }) + + for _, mr := range models { + report.Models = append(report.Models, *mr) + } + slices.SortFunc(report.Models, func(a, b ModelRow) int { + if c := cmp.Compare(b.Tokens.Total(), a.Tokens.Total()); c != 0 { + return c + } + return cmp.Compare(a.Model, b.Model) + }) + + for name, calls := range toolCalls { + report.Tools = append(report.Tools, ToolRow{Tool: name, Calls: calls}) + } + slices.SortFunc(report.Tools, func(a, b ToolRow) int { + if c := cmp.Compare(b.Calls, a.Calls); c != 0 { + return c + } + return cmp.Compare(a.Tool, b.Tool) + }) + + report.UnpricedModels = sortedKeys(unpriced) + return report +} + +// walkSession accumulates one session's spend into row and the report-wide +// tallies, recursing into sub-sessions. +// +// Sub-session spend is folded into the parent's row rather than reported +// separately: a delegating run is one thing the user started, and +// [session.Session.TotalCost] counts it the same way. The store agrees — its +// session listing is root-only — so a sub-session has nowhere else to be +// reported. +func walkSession(s *session.Session, row *SessionRow, sessionModels map[string]struct{}, + models map[string]*ModelRow, toolCalls map[string]int, +) { + if s == nil { + return + } + + // MessagesSnapshot copies under the session lock, so a live session being + // written to cannot race this walk. + for _, item := range s.MessagesSnapshot() { + if item.IsSubSession() { + walkSession(item.SubSession, row, sessionModels, models, toolCalls) + } + + model, itemUsage, itemCost := itemSpend(&item) + + row.Cost += itemCost + addUsage(&row.Tokens, itemUsage) + + if model != "" { + sessionModels[model] = struct{}{} + mr, ok := models[model] + if !ok { + mr = &ModelRow{Model: model} + models[model] = mr + } + // An attributed model with no usage still happened — the provider + // just did not report tokens (usage tracking off, or an older + // session). Counting it keeps the model visible; Unmetered records + // why its token columns are short. + mr.Calls++ + if itemUsage == nil { + mr.Unmetered++ + } + addUsage(&mr.Tokens, itemUsage) + mr.Cost += itemCost + } + + if item.IsMessage() { + for _, tc := range item.Message.Message.ToolCalls { + if tc.Function.Name != "" { + toolCalls[tc.Function.Name]++ + } + } + } + } +} + +// itemSpend returns the model, usage and cost behind one session item, whichever +// shape it takes: an assistant message, or a non-message item such as a +// compaction summary that records its own spend. +// +// An item can carry both — a message plus an item-level compaction cost — so the +// two costs are added rather than chosen between, matching +// [session.Session.TotalCost]. +func itemSpend(item *session.Item) (model string, usage *chat.Usage, cost float64) { + cost = item.Cost + if item.IsMessage() { + msg := &item.Message.Message + return msg.Model, msg.Usage, cost + msg.Cost + } + return item.Model, item.Usage, cost +} + +func sortedKeys(set map[string]struct{}) []string { + if len(set) == 0 { + return nil + } + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + slices.Sort(out) + return out +} diff --git a/pkg/usage/usage_test.go b/pkg/usage/usage_test.go new file mode 100644 index 0000000000..14af8adff8 --- /dev/null +++ b/pkg/usage/usage_test.go @@ -0,0 +1,375 @@ +package usage_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/usage" +) + +func assistant(model string, u *chat.Usage, toolNames ...string) session.Item { + return assistantCost(model, u, 0, toolNames...) +} + +func assistantCost(model string, u *chat.Usage, cost float64, toolNames ...string) session.Item { + msg := chat.Message{Role: chat.MessageRoleAssistant, Model: model, Usage: u, Cost: cost} + for _, name := range toolNames { + msg.ToolCalls = append(msg.ToolCalls, tools.ToolCall{ + Function: tools.FunctionCall{Name: name}, + }) + } + return session.Item{Message: &session.Message{AgentName: "root", Message: msg}} +} + +func day(n int) time.Time { + return time.Date(2026, 8, n, 12, 0, 0, 0, time.UTC) +} + +func TestAggregate_Empty(t *testing.T) { + t.Parallel() + got := usage.Aggregate(nil) + assert.Empty(t, got.Sessions) + assert.Empty(t, got.Models) + assert.Empty(t, got.Tools) + assert.Zero(t, got.Cost) + assert.Zero(t, got.Tokens.Total()) +} + +func TestAggregate_SumsTokensAndCostPerSession(t *testing.T) { + t.Parallel() + + s := &session.Session{ + ID: "s1", Title: "fix the bug", CreatedAt: day(1), + Messages: []session.Item{ + assistantCost("anthropic/claude-opus-5", &chat.Usage{ + InputTokens: 100, OutputTokens: 20, CachedInputTokens: 80, CacheWriteTokens: 5, + }, 0.10), + assistantCost("anthropic/claude-opus-5", &chat.Usage{ + InputTokens: 200, OutputTokens: 30, ReasoningTokens: 7, + }, 0.15), + }, + } + + got := usage.Aggregate([]*session.Session{s}) + require.Len(t, got.Sessions, 1) + + row := got.Sessions[0] + assert.Equal(t, "s1", row.ID) + assert.Equal(t, "fix the bug", row.Title) + assert.Equal(t, int64(300), row.Tokens.Input) + assert.Equal(t, int64(50), row.Tokens.Output) + assert.Equal(t, int64(80), row.Tokens.CachedInput, "cached input must be visible so caching wins can be seen") + assert.Equal(t, int64(5), row.Tokens.CacheWrite) + assert.Equal(t, int64(7), row.Tokens.Reasoning) + assert.InDelta(t, 0.25, row.Cost, 1e-9) + assert.Equal(t, []string{"anthropic/claude-opus-5"}, row.Models) + + assert.InDelta(t, 0.25, got.Cost, 1e-9, "report total is the sum of session costs") + assert.Equal(t, int64(350), got.Tokens.Input+got.Tokens.Output) +} + +func TestAggregate_AttributesTokensPerModel(t *testing.T) { + t.Parallel() + + s := &session.Session{ + ID: "s1", CreatedAt: day(1), + Messages: []session.Item{ + assistant("anthropic/claude-opus-5", &chat.Usage{InputTokens: 100, OutputTokens: 10}), + assistant("openai/gpt-5", &chat.Usage{InputTokens: 300, OutputTokens: 40}), + assistant("openai/gpt-5", &chat.Usage{InputTokens: 50, OutputTokens: 5}), + }, + } + + got := usage.Aggregate([]*session.Session{s}) + require.Len(t, got.Models, 2) + + // Sorted by total tokens, descending: gpt-5 (395) before opus-5 (110). + assert.Equal(t, "openai/gpt-5", got.Models[0].Model) + assert.Equal(t, 2, got.Models[0].Calls) + assert.Equal(t, int64(350), got.Models[0].Tokens.Input) + assert.Equal(t, "anthropic/claude-opus-5", got.Models[1].Model) + assert.Equal(t, 1, got.Models[1].Calls) + + assert.Equal(t, []string{"anthropic/claude-opus-5", "openai/gpt-5"}, got.Sessions[0].Models, + "a session's models are listed sorted and de-duplicated") +} + +func TestAggregate_CountsToolCalls(t *testing.T) { + t.Parallel() + + s := &session.Session{ + ID: "s1", CreatedAt: day(1), + Messages: []session.Item{ + assistant("m", &chat.Usage{InputTokens: 1}, "read_file", "read_file", "shell"), + assistant("m", &chat.Usage{InputTokens: 1}, "read_file"), + }, + } + + got := usage.Aggregate([]*session.Session{s}) + require.Len(t, got.Tools, 2) + assert.Equal(t, "read_file", got.Tools[0].Tool, "sorted by call count, descending") + assert.Equal(t, 3, got.Tools[0].Calls) + assert.Equal(t, "shell", got.Tools[1].Tool) + assert.Equal(t, 1, got.Tools[1].Calls) +} + +// A model missing from the pricing catalogue records $0 cost despite real token +// usage. Reporting that as "$0.00" would silently under-report spend, so it must +// be flagged instead. +func TestAggregate_FlagsUnpricedUsage(t *testing.T) { + t.Parallel() + + priced := &session.Session{ + ID: "priced", CreatedAt: day(2), + Messages: []session.Item{assistantCost("anthropic/claude-opus-5", &chat.Usage{InputTokens: 100, OutputTokens: 10}, 0.5)}, + } + unpriced := &session.Session{ + ID: "unpriced", CreatedAt: day(1), Cost: 0, + Messages: []session.Item{assistant("test/fake-root", &chat.Usage{InputTokens: 40, OutputTokens: 10})}, + } + + got := usage.Aggregate([]*session.Session{priced, unpriced}) + + assert.Equal(t, []string{"test/fake-root"}, got.UnpricedModels) + + byID := map[string]usage.SessionRow{} + for _, r := range got.Sessions { + byID[r.ID] = r + } + assert.True(t, byID["unpriced"].CostIncomplete, "a session with tokens but no cost is flagged") + assert.False(t, byID["priced"].CostIncomplete) +} + +func TestAggregate_SortsSessionsNewestFirst(t *testing.T) { + t.Parallel() + + got := usage.Aggregate([]*session.Session{ + {ID: "old", CreatedAt: day(1)}, + {ID: "new", CreatedAt: day(3)}, + {ID: "mid", CreatedAt: day(2)}, + }) + + require.Len(t, got.Sessions, 3) + assert.Equal(t, []string{"new", "mid", "old"}, + []string{got.Sessions[0].ID, got.Sessions[1].ID, got.Sessions[2].ID}) +} + +// Compaction and other non-message operations carry their own Usage and Cost on +// the item rather than on a message; they are real spend and must be counted. +func TestAggregate_IncludesNonMessageItemUsage(t *testing.T) { + t.Parallel() + + s := &session.Session{ + ID: "s1", CreatedAt: day(1), + Messages: []session.Item{ + assistantCost("anthropic/claude-opus-5", &chat.Usage{InputTokens: 100, OutputTokens: 10}, 0.3), + {Model: "anthropic/claude-opus-5", Cost: 0.1, Usage: &chat.Usage{InputTokens: 900, OutputTokens: 50}}, + }, + } + + got := usage.Aggregate([]*session.Session{s}) + assert.Equal(t, int64(1000), got.Sessions[0].Tokens.Input, "compaction tokens count too") + require.Len(t, got.Models, 1) + assert.Equal(t, 2, got.Models[0].Calls) +} + +func TestAggregate_ToleratesMissingUsageAndModels(t *testing.T) { + t.Parallel() + + s := &session.Session{ + ID: "s1", CreatedAt: day(1), + Messages: []session.Item{ + {}, // neither a message nor usage + {Message: &session.Message{Message: chat.Message{Role: chat.MessageRoleUser}}}, + assistant("", nil), // assistant with no usage and no model + }, + } + + got := usage.Aggregate([]*session.Session{s}) + require.Len(t, got.Sessions, 1) + assert.Zero(t, got.Sessions[0].Tokens.Total()) + assert.Empty(t, got.Models, "a message with no model and no usage contributes no model row") +} + +func TestTokens_Total(t *testing.T) { + t.Parallel() + // Cached input is part of input, and cache writes are billed separately; + // Total is the headline "tokens moved" figure, so it counts input+output only. + tk := usage.Tokens{Input: 100, CachedInput: 90, CacheWrite: 10, Output: 5, Reasoning: 3} + assert.Equal(t, int64(105), tk.Total()) +} + +// An attributed model whose provider reported no usage still made a call. The +// call is counted — dropping it would hide a model served entirely by a +// usage-tracking-disabled provider — and Unmetered explains the short tokens. +func TestAggregate_UnmeteredCallsAreCountedAndFlagged(t *testing.T) { + t.Parallel() + + s := &session.Session{ + ID: "s1", CreatedAt: day(1), + Messages: []session.Item{ + assistant("openai/gpt-5", &chat.Usage{InputTokens: 100, OutputTokens: 10}), + assistant("openai/gpt-5", nil), // model attributed, no usage reported + // A non-message item (compaction) with a model but no usage. + {Model: "openai/gpt-5"}, + }, + } + + got := usage.Aggregate([]*session.Session{s}) + require.Len(t, got.Models, 1) + + assert.Equal(t, 3, got.Models[0].Calls, "every attributed response counts as a call") + assert.Equal(t, 2, got.Models[0].Unmetered, "the two without usage are flagged") + assert.Equal(t, int64(100), got.Models[0].Tokens.Input, "tokens only come from reported usage") +} + +// Total() is input+output by design, so a run that burned only reasoning tokens +// reports Total()==0. Keying the unpriced check on Total would silently report +// such a session as a genuine $0.00. +func TestAggregate_ReasoningOnlySpendIsFlaggedAsUnpriced(t *testing.T) { + t.Parallel() + + s := &session.Session{ + ID: "s1", CreatedAt: day(1), Cost: 0, + Messages: []session.Item{ + assistant("test/fake-root", &chat.Usage{ReasoningTokens: 500}), + }, + } + + got := usage.Aggregate([]*session.Session{s}) + require.Len(t, got.Sessions, 1) + + require.Zero(t, got.Sessions[0].Tokens.Total(), "Total deliberately excludes reasoning") + assert.True(t, got.Sessions[0].Tokens.AnySpend(), "but something was consumed") + assert.True(t, got.Sessions[0].CostIncomplete, "so the zero cost must be flagged") + assert.Equal(t, []string{"test/fake-root"}, got.UnpricedModels) +} + +func TestTokens_AnySpend(t *testing.T) { + t.Parallel() + + assert.False(t, usage.Tokens{}.AnySpend()) + for name, tk := range map[string]usage.Tokens{ + "input": {Input: 1}, + "output": {Output: 1}, + "cached": {CachedInput: 1}, + "cache write": {CacheWrite: 1}, + "reasoning": {Reasoning: 1}, + } { + assert.Truef(t, tk.AnySpend(), "%s alone counts as spend", name) + } +} + +// Delegated work lives in sub-sessions, and the store's session listing is +// root-only — so a sub-agent's spend has nowhere to be reported except the +// parent's row. Missing it understated a real multi-agent session by 99.8%. +func TestAggregate_IncludesSubSessionSpend(t *testing.T) { + t.Parallel() + + sub := &session.Session{ID: "sub", Messages: []session.Item{ + assistantCost("openai/gpt-5", &chat.Usage{InputTokens: 900, OutputTokens: 90}, 0.9, "read_file"), + }} + parent := &session.Session{ + ID: "root", CreatedAt: day(1), + Messages: []session.Item{ + assistantCost("anthropic/claude-opus-5", &chat.Usage{InputTokens: 100, OutputTokens: 10}, 0.1, "transfer_task"), + session.NewSubSessionItem(sub), + }, + } + + got := usage.Aggregate([]*session.Session{parent}) + require.Len(t, got.Sessions, 1) + + row := got.Sessions[0] + assert.Equal(t, int64(1000), row.Tokens.Input, "sub-agent tokens count") + assert.Equal(t, int64(100), row.Tokens.Output) + assert.InDelta(t, 1.0, row.Cost, 1e-9, "sub-agent cost counts") + assert.Equal(t, []string{"anthropic/claude-opus-5", "openai/gpt-5"}, row.Models) + + // The sub-agent's model and tool calls appear in the breakdowns too. + require.Len(t, got.Models, 2) + assert.Equal(t, "openai/gpt-5", got.Models[0].Model) + assert.InDelta(t, 0.9, got.Models[0].Cost, 1e-9) + + toolCalls := map[string]int{} + for _, tr := range got.Tools { + toolCalls[tr.Tool] = tr.Calls + } + assert.Equal(t, map[string]int{"read_file": 1, "transfer_task": 1}, toolCalls) +} + +func TestAggregate_RecursesThroughNestedSubSessions(t *testing.T) { + t.Parallel() + + deep := &session.Session{ID: "deep", Messages: []session.Item{ + assistantCost("m", &chat.Usage{InputTokens: 5}, 0.05), + }} + mid := &session.Session{ID: "mid", Messages: []session.Item{ + assistantCost("m", &chat.Usage{InputTokens: 50}, 0.5), + session.NewSubSessionItem(deep), + }} + root := &session.Session{ID: "root", CreatedAt: day(1), Messages: []session.Item{ + assistantCost("m", &chat.Usage{InputTokens: 500}, 5), + session.NewSubSessionItem(mid), + }} + + got := usage.Aggregate([]*session.Session{root}) + require.Len(t, got.Sessions, 1) + assert.Equal(t, int64(555), got.Sessions[0].Tokens.Input) + assert.InDelta(t, 5.55, got.Sessions[0].Cost, 1e-9) +} + +// Cost must agree with session.TotalCost, which is what every other consumer +// reports. The legacy session-level Cost field is only kept for backward- +// compatible persistence and understates a delegating run. +func TestAggregate_CostMatchesSessionTotalCost(t *testing.T) { + t.Parallel() + + sub := &session.Session{ID: "sub", Messages: []session.Item{ + assistantCost("m", &chat.Usage{InputTokens: 10}, 2.5), + }} + root := &session.Session{ + ID: "root", CreatedAt: day(1), + // The legacy field is deliberately left at a stale value: it must not + // be what the report reads. + Cost: 0.01, + Messages: []session.Item{ + assistantCost("m", &chat.Usage{InputTokens: 10}, 1.25), + session.NewSubSessionItem(sub), + // A compaction item carries its own cost alongside messages. + {Model: "m", Cost: 0.25, Usage: &chat.Usage{InputTokens: 100}}, + }, + } + + got := usage.Aggregate([]*session.Session{root}) + require.Len(t, got.Sessions, 1) + assert.InDelta(t, root.TotalCost(), got.Sessions[0].Cost, 1e-9) + assert.InDelta(t, 4.0, got.Sessions[0].Cost, 1e-9) +} + +// Cost is attributable per model, since it is recorded per message. +func TestAggregate_AttributesCostPerModel(t *testing.T) { + t.Parallel() + + s := &session.Session{ID: "s1", CreatedAt: day(1), Messages: []session.Item{ + assistantCost("openai/gpt-5", &chat.Usage{InputTokens: 300}, 0.75), + assistantCost("anthropic/claude-opus-5", &chat.Usage{InputTokens: 100}, 0.25), + assistantCost("openai/gpt-5", &chat.Usage{InputTokens: 200}, 0.50), + }} + + got := usage.Aggregate([]*session.Session{s}) + require.Len(t, got.Models, 2) + + byModel := map[string]float64{} + for _, m := range got.Models { + byModel[m.Model] = m.Cost + } + assert.InDelta(t, 1.25, byModel["openai/gpt-5"], 1e-9) + assert.InDelta(t, 0.25, byModel["anthropic/claude-opus-5"], 1e-9) +}