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
2 changes: 1 addition & 1 deletion cmd/agentledger/costs.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func runCosts(configPath, last, groupBy, tenant string) error {

var store ledger.Ledger
switch cfg.Storage.Driver {
case "postgres":
case "postgres": //nolint:goconst
store, err = ledger.NewPostgres(cfg.Storage.DSN, cfg.Storage.MaxOpenConns, cfg.Storage.MaxIdleConns)
default:
store, err = ledger.NewSQLite(cfg.Storage.DSN)
Expand Down
120 changes: 120 additions & 0 deletions cmd/agentledger/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"os"
"strconv"
"time"

"github.com/spf13/cobra"

"github.com/WDZ-Dev/agent-ledger/internal/config"
"github.com/WDZ-Dev/agent-ledger/internal/ledger"
)

func exportCmd() *cobra.Command {
var (
configPath string
last string
groupBy string
tenant string
format string
)

cmd := &cobra.Command{
Use: "export",
Short: "Export cost data as CSV or JSON",
RunE: func(_ *cobra.Command, _ []string) error {
return runExport(configPath, last, groupBy, tenant, format)
},
}

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

return cmd
}

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

var store ledger.Ledger
switch cfg.Storage.Driver {
case "postgres": //nolint:goconst
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
}
defer func() { _ = store.Close() }()

window, err := parseDuration(last)
if err != nil {
return fmt.Errorf("invalid --last value %q: %w", last, err)
}

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

entries, err := store.QueryCosts(context.Background(), filter)
if err != nil {
return err
}

switch format {
case "json":
return json.NewEncoder(os.Stdout).Encode(entries)
case "csv":
return writeCSV(os.Stdout, entries)
default:
return fmt.Errorf("unsupported format %q: use csv or json", format)
}
}

func writeCSV(out *os.File, entries []ledger.CostEntry) error {
w := csv.NewWriter(out)
defer w.Flush()

header := []string{
"provider", "model", "api_key_hash", "agent_id", "session_id",
"requests", "input_tokens", "output_tokens", "cost_usd",
}
if err := w.Write(header); err != nil {
return err
}

for _, e := range entries {
record := []string{
e.Provider,
e.Model,
e.APIKeyHash,
e.AgentID,
e.SessionID,
strconv.Itoa(e.Requests),
strconv.FormatInt(e.InputTokens, 10),
strconv.FormatInt(e.OutputTokens, 10),
strconv.FormatFloat(e.TotalCostUSD, 'f', 6, 64),
}
if err := w.Write(record); err != nil {
return err
}
}

return nil
}
1 change: 1 addition & 0 deletions cmd/agentledger/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func main() {

root.AddCommand(serveCmd())
root.AddCommand(costsCmd())
root.AddCommand(exportCmd())
root.AddCommand(mcpWrapCmd())
root.AddCommand(newVersionCmd())
root.AddCommand(newHealthcheckCmd())
Expand Down
18 changes: 13 additions & 5 deletions cmd/agentledger/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func runServe(configPath string) error {
DB() *sql.DB
}
switch cfg.Storage.Driver {
case "postgres":
case "postgres": //nolint:goconst
pgStore, pgErr := ledger.NewPostgres(cfg.Storage.DSN, cfg.Storage.MaxOpenConns, cfg.Storage.MaxIdleConns)
if pgErr != nil {
return pgErr
Expand Down Expand Up @@ -102,6 +102,7 @@ func runServe(configPath string) error {
for _, r := range cfg.Budgets.Rules {
budgetCfg.Rules = append(budgetCfg.Rules, budget.Rule{
APIKeyPattern: r.APIKeyPattern,
TenantID: r.TenantID,
DailyLimitUSD: r.DailyLimitUSD,
MonthlyLimitUSD: r.MonthlyLimitUSD,
SoftLimitPct: r.SoftLimitPct,
Expand Down Expand Up @@ -276,8 +277,16 @@ func runServe(configPath string) error {
}
}

// Admin blocklist (optional — created early so the proxy can use it).
var blocklist *admin.Blocklist
var adminStore *admin.Store
if cfg.Admin.Enabled && cfg.Admin.Token != "" {
adminStore = admin.NewStore(store.DB())
blocklist = admin.NewBlocklist(adminStore)
}

// Proxy
p := proxy.New(reg, m, rec, budgetMgr, tracker, metrics, limiter, tenantResolver, transport, logger)
p := proxy.New(reg, m, rec, budgetMgr, tracker, metrics, limiter, tenantResolver, blocklist, transport, logger)

// HTTP routing:
// /v1/* → LLM proxy
Expand Down Expand Up @@ -313,9 +322,8 @@ func runServe(configPath string) error {
}

// Admin API (optional).
if cfg.Admin.Enabled && cfg.Admin.Token != "" {
adminStore := admin.NewStore(store.DB())
adminHandler := admin.NewHandler(adminStore, store, budgetMgr, cfg.Admin.Token)
if adminStore != nil && cfg.Admin.Token != "" {
adminHandler := admin.NewHandler(adminStore, store, budgetMgr, cfg.Admin.Token, blocklist)
adminHandler.RegisterRoutes(mux)
logger.Info("admin API enabled")
}
Expand Down
4 changes: 4 additions & 0 deletions configs/agentledger.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ recording:
# daily_limit_usd: 5.0
# monthly_limit_usd: 50.0
# action: "block"
# - tenant_id: "alpha"
# daily_limit_usd: 100.0
# monthly_limit_usd: 1000.0
# action: "block"

# Circuit breaker (optional — omit to disable)
# circuit_breaker:
Expand Down
Loading
Loading