Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Thumbs.db
# Build
dist/
build/output/
/agentledger

# Node (dashboard)
web/node_modules/
Expand Down
21 changes: 15 additions & 6 deletions cmd/agentledger/costs.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,30 +31,38 @@ func costsCmd() *cobra.Command {
configPath string
last string
groupBy string
tenant string
)

cmd := &cobra.Command{
Use: "costs",
Short: "Show cost report",
RunE: func(_ *cobra.Command, _ []string) error {
return runCosts(configPath, last, groupBy)
return runCosts(configPath, last, groupBy, tenant)
},
}

cmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
cmd.Flags().StringVar(&last, "last", "24h", "time window (e.g., 1h, 24h, 7d)")
cmd.Flags().StringVar(&groupBy, "by", "model", "group by: model, provider, key, agent, session")
cmd.Flags().StringVar(&tenant, "tenant", "", "filter by tenant ID")

return cmd
}

func runCosts(configPath, last, groupBy string) error {
func runCosts(configPath, last, groupBy, tenant string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}

store, err := ledger.NewSQLite(cfg.Storage.DSN)
var store ledger.Ledger
switch cfg.Storage.Driver {
case "postgres":
store, err = ledger.NewPostgres(cfg.Storage.DSN, cfg.Storage.MaxOpenConns, cfg.Storage.MaxIdleConns)
default:
store, err = ledger.NewSQLite(cfg.Storage.DSN)
}
if err != nil {
return err
}
Expand All @@ -67,9 +75,10 @@ func runCosts(configPath, last, groupBy string) error {

now := time.Now()
filter := ledger.CostFilter{
Since: now.Add(-window),
Until: now,
GroupBy: groupBy,
Since: now.Add(-window),
Until: now,
GroupBy: groupBy,
TenantID: tenant,
}

entries, err := store.QueryCosts(context.Background(), filter)
Expand Down
2 changes: 1 addition & 1 deletion internal/budget/budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func (s *stubLedger) GetTotalSpend(_ context.Context, _ string, since, _ time.Ti
func (s *stubLedger) GetTotalSpendByTenant(_ context.Context, _ string, _, _ time.Time) (float64, error) {
return 0, nil
}
func (s *stubLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time) ([]ledger.TimeseriesPoint, error) {
func (s *stubLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]ledger.TimeseriesPoint, error) {
return nil, nil
}
func (s *stubLedger) Close() error { return nil }
Expand Down
19 changes: 12 additions & 7 deletions internal/dashboard/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ func (h *Handler) handleSummary(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
tenantID := r.URL.Query().Get("tenant")

// Get today's costs by model.
todayCosts, err := h.ledger.QueryCosts(r.Context(), ledger.CostFilter{
Since: dayStart,
Until: now,
GroupBy: "model",
Since: dayStart,
Until: now,
GroupBy: "model",
TenantID: tenantID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
Expand All @@ -54,9 +56,10 @@ func (h *Handler) handleSummary(w http.ResponseWriter, r *http.Request) {

// Get month's costs.
monthCosts, err := h.ledger.QueryCosts(r.Context(), ledger.CostFilter{
Since: monthStart,
Until: now,
GroupBy: "model",
Since: monthStart,
Until: now,
GroupBy: "model",
TenantID: tenantID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
Expand Down Expand Up @@ -93,10 +96,12 @@ func (h *Handler) handleTimeseries(w http.ResponseWriter, r *http.Request) {
hours = 24
}

tenantID := r.URL.Query().Get("tenant")

now := time.Now().UTC()
since := now.Add(-time.Duration(hours) * time.Hour)

points, err := h.ledger.QueryCostTimeseries(r.Context(), interval, since, now)
points, err := h.ledger.QueryCostTimeseries(r.Context(), interval, since, now, tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
Expand Down
62 changes: 61 additions & 1 deletion internal/dashboard/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (s *stubLedger) GetTotalSpend(_ context.Context, _ string, _, _ time.Time)
func (s *stubLedger) GetTotalSpendByTenant(_ context.Context, _ string, _, _ time.Time) (float64, error) {
return 0, nil
}
func (s *stubLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time) ([]ledger.TimeseriesPoint, error) {
func (s *stubLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]ledger.TimeseriesPoint, error) {
return s.timeseries, nil
}
func (s *stubLedger) Close() error { return nil }
Expand Down Expand Up @@ -120,6 +120,66 @@ func TestHandleCosts(t *testing.T) {
}
}

func TestHandleCostsWithTenant(t *testing.T) {
store := &stubLedger{
costs: []ledger.CostEntry{
{Model: "gpt-4o-mini", Requests: 3, TotalCostUSD: 0.15},
},
}
h := NewHandler(store, nil)

mux := http.NewServeMux()
h.RegisterRoutes(mux)

req := httptest.NewRequest("GET", "/api/dashboard/costs?group_by=model&tenant=alpha", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)

if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
}

func TestHandleSummaryWithTenant(t *testing.T) {
store := &stubLedger{
costs: []ledger.CostEntry{
{Model: "gpt-4o-mini", Requests: 5, TotalCostUSD: 0.25},
},
}
h := NewHandler(store, nil)

mux := http.NewServeMux()
h.RegisterRoutes(mux)

req := httptest.NewRequest("GET", "/api/dashboard/summary?tenant=beta", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)

if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
}

func TestHandleTimeseriesWithTenant(t *testing.T) {
store := &stubLedger{
timeseries: []ledger.TimeseriesPoint{
{Timestamp: time.Now(), CostUSD: 0.10, Requests: 2},
},
}
h := NewHandler(store, nil)

mux := http.NewServeMux()
h.RegisterRoutes(mux)

req := httptest.NewRequest("GET", "/api/dashboard/timeseries?tenant=gamma", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)

if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
}

func TestHandleSessionsWithoutTracker(t *testing.T) {
store := &stubLedger{}
h := NewHandler(store, nil)
Expand Down
4 changes: 2 additions & 2 deletions internal/ledger/ledger.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ type Ledger interface {
GetTotalSpendByTenant(ctx context.Context, tenantID string, since, until time.Time) (float64, error)

// QueryCostTimeseries returns cost and request counts bucketed by time interval.
// interval should be "hour" or "day".
QueryCostTimeseries(ctx context.Context, interval string, since, until time.Time) ([]TimeseriesPoint, error)
// interval should be "hour" or "day". tenantID is optional (empty = all tenants).
QueryCostTimeseries(ctx context.Context, interval string, since, until time.Time, tenantID string) ([]TimeseriesPoint, error)

// Close releases any held resources.
Close() error
Expand Down
27 changes: 0 additions & 27 deletions internal/ledger/migrations/001_create_usage_records.sql

This file was deleted.

19 changes: 0 additions & 19 deletions internal/ledger/migrations/002_create_agent_sessions.sql

This file was deleted.

9 changes: 0 additions & 9 deletions internal/ledger/migrations/003_add_tenant_id.sql

This file was deleted.

9 changes: 0 additions & 9 deletions internal/ledger/migrations/004_create_admin_config.sql

This file was deleted.

15 changes: 11 additions & 4 deletions internal/ledger/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,22 +127,29 @@ func (p *Postgres) QueryCosts(ctx context.Context, filter CostFilter) ([]CostEnt
return entries, rows.Err()
}

func (p *Postgres) QueryCostTimeseries(ctx context.Context, interval string, since, until time.Time) ([]TimeseriesPoint, error) {
func (p *Postgres) QueryCostTimeseries(ctx context.Context, interval string, since, until time.Time, tenantID string) ([]TimeseriesPoint, error) {
bucket := "date_trunc('hour', timestamp)"
if interval == "day" {
bucket = "date_trunc('day', timestamp)"
}

where := "timestamp >= $1 AND timestamp <= $2"
args := []any{since.UTC(), until.UTC()}
if tenantID != "" {
args = append(args, tenantID)
where += fmt.Sprintf(" AND tenant_id = $%d", len(args))
}

q := fmt.Sprintf(`SELECT
%s as bucket,
COALESCE(SUM(cost_usd), 0),
COUNT(*)
FROM usage_records
WHERE timestamp >= $1 AND timestamp <= $2
WHERE %s
GROUP BY bucket
ORDER BY bucket ASC`, bucket)
ORDER BY bucket ASC`, bucket, where)

rows, err := p.db.QueryContext(ctx, q, since.UTC(), until.UTC())
rows, err := p.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("querying cost timeseries: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/ledger/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ func TestPostgres_QueryCostTimeseries(t *testing.T) {
}
}

points, err := pg.QueryCostTimeseries(ctx, "hour", hourAgo.Add(-time.Minute), now.Add(time.Minute))
points, err := pg.QueryCostTimeseries(ctx, "hour", hourAgo.Add(-time.Minute), now.Add(time.Minute), "")
if err != nil {
t.Fatalf("QueryCostTimeseries: %v", err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/ledger/recorder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func (c *countingLedger) GetTotalSpendByTenant(_ context.Context, _ string, _, _
return 0, nil
}

func (c *countingLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time) ([]TimeseriesPoint, error) {
func (c *countingLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]TimeseriesPoint, error) {
return nil, nil
}

Expand All @@ -53,7 +53,7 @@ func (f *failingLedger) GetTotalSpendByTenant(_ context.Context, _ string, _, _
return 0, nil
}

func (f *failingLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time) ([]TimeseriesPoint, error) {
func (f *failingLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]TimeseriesPoint, error) {
return nil, nil
}

Expand Down
15 changes: 11 additions & 4 deletions internal/ledger/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,24 +129,31 @@ func (s *SQLite) QueryCosts(ctx context.Context, filter CostFilter) ([]CostEntry
return entries, rows.Err()
}

func (s *SQLite) QueryCostTimeseries(ctx context.Context, interval string, since, until time.Time) ([]TimeseriesPoint, error) {
func (s *SQLite) QueryCostTimeseries(ctx context.Context, interval string, since, until time.Time, tenantID string) ([]TimeseriesPoint, error) {
// Go's time.Time stores as "2006-01-02 15:04:05.999999 +0000 UTC" in SQLite,
// but strftime only parses ISO8601. Use substr to extract the datetime portion.
bucket := "strftime('%Y-%m-%d %H:00:00', substr(timestamp, 1, 19))"
if interval == "day" {
bucket = "strftime('%Y-%m-%d 00:00:00', substr(timestamp, 1, 19))"
}

where := "timestamp >= ? AND timestamp <= ?"
args := []any{since.UTC(), until.UTC()}
if tenantID != "" {
where += " AND tenant_id = ?"
args = append(args, tenantID)
}

q := fmt.Sprintf(`SELECT
%s as bucket,
COALESCE(SUM(cost_usd), 0),
COUNT(*)
FROM usage_records
WHERE timestamp >= ? AND timestamp <= ?
WHERE %s
GROUP BY bucket
ORDER BY bucket ASC`, bucket)
ORDER BY bucket ASC`, bucket, where)

rows, err := s.db.QueryContext(ctx, q, since.UTC(), until.UTC())
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("querying cost timeseries: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/interceptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (r *recordingLedger) GetTotalSpendByTenant(_ context.Context, _ string, _,
return 0, nil
}

func (r *recordingLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time) ([]ledger.TimeseriesPoint, error) {
func (r *recordingLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]ledger.TimeseriesPoint, error) {
return nil, nil
}

Expand Down
2 changes: 1 addition & 1 deletion internal/proxy/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func (m *mockStore) GetTotalSpendByTenant(_ context.Context, _ string, _, _ time
return m.totalSpend, nil
}

func (m *mockStore) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time) ([]ledger.TimeseriesPoint, error) {
func (m *mockStore) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]ledger.TimeseriesPoint, error) {
return nil, nil
}

Expand Down
Loading