From 95bc87a6f5d2c9625522691519b5e7702ac31246 Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Mon, 16 Mar 2026 18:41:35 -0700 Subject: [PATCH 1/4] Add CSV/JSON export, API key blocklist, per-tenant budgets, OpenAI Responses API, and Grafana dashboard - CLI `agentledger export` and REST `/api/dashboard/export` for CSV/JSON cost export - API key blocklist with glob patterns, TTL cache, and admin CRUD endpoints - Per-tenant budget enforcement (tenant-scoped spend lookups, stricter result wins) - OpenAI Responses API (`/v1/responses`) request/response/stream parsing - Grafana 10.x dashboard template with 10 panels across 4 rows - Example config updates for tenant budget rules --- cmd/agentledger/export.go | 120 +++++ cmd/agentledger/main.go | 1 + cmd/agentledger/serve.go | 18 +- configs/agentledger.example.yaml | 4 + deploy/grafana/agentledger.json | 654 ++++++++++++++++++++++++ internal/admin/admin_test.go | 8 +- internal/admin/blocklist.go | 61 +++ internal/admin/handlers.go | 89 +++- internal/budget/budget.go | 124 ++++- internal/budget/budget_test.go | 22 +- internal/config/config.go | 3 +- internal/dashboard/handlers.go | 72 +++ internal/provider/openai_compat.go | 66 ++- internal/provider/openai_compat_test.go | 8 + internal/provider/responses_test.go | 75 +++ internal/proxy/proxy.go | 25 +- internal/proxy/proxy_bench_test.go | 6 +- internal/proxy/proxy_test.go | 14 +- 18 files changed, 1303 insertions(+), 67 deletions(-) create mode 100644 cmd/agentledger/export.go create mode 100644 deploy/grafana/agentledger.json create mode 100644 internal/admin/blocklist.go create mode 100644 internal/provider/responses_test.go diff --git a/cmd/agentledger/export.go b/cmd/agentledger/export.go new file mode 100644 index 0000000..ce48ea5 --- /dev/null +++ b/cmd/agentledger/export.go @@ -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": + 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 +} diff --git a/cmd/agentledger/main.go b/cmd/agentledger/main.go index 8980cae..3e1bb5e 100644 --- a/cmd/agentledger/main.go +++ b/cmd/agentledger/main.go @@ -19,6 +19,7 @@ func main() { root.AddCommand(serveCmd()) root.AddCommand(costsCmd()) + root.AddCommand(exportCmd()) root.AddCommand(mcpWrapCmd()) root.AddCommand(newVersionCmd()) root.AddCommand(newHealthcheckCmd()) diff --git a/cmd/agentledger/serve.go b/cmd/agentledger/serve.go index 08535fc..41fa9ca 100644 --- a/cmd/agentledger/serve.go +++ b/cmd/agentledger/serve.go @@ -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 @@ -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, @@ -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 @@ -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") } diff --git a/configs/agentledger.example.yaml b/configs/agentledger.example.yaml index c97ee0c..f9e1d0a 100644 --- a/configs/agentledger.example.yaml +++ b/configs/agentledger.example.yaml @@ -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: diff --git a/deploy/grafana/agentledger.json b/deploy/grafana/agentledger.json new file mode 100644 index 0000000..5c785ae --- /dev/null +++ b/deploy/grafana/agentledger.json @@ -0,0 +1,654 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Monitoring dashboard for AgentLedger — LLM proxy cost, usage, and rate-limit tracking.", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Overview Stats", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "red", "value": 200 } + ] + }, + "unit": "currencyUSD" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "sum(increase(agentledger_cost_usd_total[$__range]))", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "A" + } + ], + "title": "Total Spend (24h)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "blue", "value": 1000 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "sum(increase(agentledger_request_total[$__range]))", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "A" + } + ], + "title": "Total Requests (24h)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 50 }, + { "color": "red", "value": 100 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "agentledger_active_sessions", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Active Sessions", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "sum(increase(agentledger_alerts_total[$__range]))", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "A" + } + ], + "title": "Alerts Fired", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 101, + "title": "Cost Analysis", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 30, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "currencyUSD" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 5, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "sum by (provider) (rate(agentledger_cost_usd_total[5m]) * 300)", + "instant": false, + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Cost Over Time", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "mappings": [], + "unit": "currencyUSD" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 6, + "options": { + "displayLabels": ["name", "percent"], + "legend": { "displayMode": "table", "placement": "right", "values": ["value", "percent"] }, + "pieType": "donut", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "sum by (model) (increase(agentledger_cost_usd_total[$__range]))", + "instant": true, + "legendFormat": "{{model}}", + "range": false, + "refId": "A" + } + ], + "title": "Cost by Model", + "type": "piechart" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "id": 102, + "title": "Request Analysis", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "id": 7, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "sum by (provider) (rate(agentledger_request_total[5m]))", + "instant": false, + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Requests per Second", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 500 }, + { "color": "red", "value": 2000 } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "id": 8, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by (le) (rate(agentledger_request_duration_ms_bucket[5m])))", + "instant": false, + "legendFormat": "p95", + "range": true, + "refId": "A" + } + ], + "title": "Request Duration (p95)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, + "id": 103, + "title": "Token & Rate Limits", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "tokens / 5m", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 30, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "prompt" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byName", "options": "completion" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } } + ] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 }, + "id": 9, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "sum by (type) (rate(agentledger_tokens_total[5m]) * 300)", + "instant": false, + "legendFormat": "{{type}}", + "range": true, + "refId": "A" + } + ], + "title": "Tokens by Type", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 }, + "id": 10, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "$datasource" }, + "editorMode": "code", + "expr": "sum(rate(agentledger_rate_limited_total[5m]) * 300)", + "instant": false, + "legendFormat": "Rate Limited", + "range": true, + "refId": "A" + } + ], + "title": "Rate Limited Requests", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["agentledger", "llm", "ai"], + "templating": { + "list": [ + { + "current": {}, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "auto": true, + "auto_count": 30, + "auto_min": "10s", + "current": { + "selected": false, + "text": "auto", + "value": "$__auto_interval_interval" + }, + "hide": 0, + "label": "Interval", + "name": "interval", + "options": [ + { "selected": false, "text": "auto", "value": "$__auto_interval_interval" }, + { "selected": false, "text": "1m", "value": "1m" }, + { "selected": false, "text": "5m", "value": "5m" }, + { "selected": false, "text": "15m", "value": "15m" }, + { "selected": false, "text": "1h", "value": "1h" }, + { "selected": false, "text": "6h", "value": "6h" }, + { "selected": false, "text": "1d", "value": "1d" } + ], + "query": "1m,5m,15m,1h,6h,1d", + "queryValue": "", + "refresh": 2, + "skipUrlSync": false, + "type": "interval" + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "AgentLedger", + "uid": "agentledger", + "version": 1, + "weekStart": "" +} diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go index e8775e4..7c7fc8e 100644 --- a/internal/admin/admin_test.go +++ b/internal/admin/admin_test.go @@ -127,7 +127,7 @@ func TestStore_ListAll(t *testing.T) { func TestHandler_RequiresAuth(t *testing.T) { db := setupTestDB(t) store := admin.NewStore(db) - handler := admin.NewHandler(store, nil, nil, "secret-token") + handler := admin.NewHandler(store, nil, nil, "secret-token", nil) mux := http.NewServeMux() handler.RegisterRoutes(mux) @@ -162,7 +162,7 @@ func TestHandler_RequiresAuth(t *testing.T) { func TestHandler_CRUDRules(t *testing.T) { db := setupTestDB(t) store := admin.NewStore(db) - handler := admin.NewHandler(store, nil, nil, "token") + handler := admin.NewHandler(store, nil, nil, "token", nil) mux := http.NewServeMux() handler.RegisterRoutes(mux) @@ -226,7 +226,7 @@ func TestHandler_CRUDRules(t *testing.T) { func TestHandler_DeleteNonExistent(t *testing.T) { db := setupTestDB(t) store := admin.NewStore(db) - handler := admin.NewHandler(store, nil, nil, "token") + handler := admin.NewHandler(store, nil, nil, "token", nil) mux := http.NewServeMux() handler.RegisterRoutes(mux) @@ -243,7 +243,7 @@ func TestHandler_DeleteNonExistent(t *testing.T) { func TestHandler_NoToken(t *testing.T) { db := setupTestDB(t) store := admin.NewStore(db) - handler := admin.NewHandler(store, nil, nil, "") + handler := admin.NewHandler(store, nil, nil, "", nil) mux := http.NewServeMux() handler.RegisterRoutes(mux) diff --git a/internal/admin/blocklist.go b/internal/admin/blocklist.go new file mode 100644 index 0000000..3341133 --- /dev/null +++ b/internal/admin/blocklist.go @@ -0,0 +1,61 @@ +package admin + +import ( + "context" + "path/filepath" + "sync" + "time" +) + +// Blocklist checks API keys against a list of blocked glob patterns. +type Blocklist struct { + store *Store + patterns []string + mu sync.RWMutex + lastLoad time.Time + ttl time.Duration +} + +// NewBlocklist creates a Blocklist backed by the admin config store. +func NewBlocklist(store *Store) *Blocklist { + return &Blocklist{ + store: store, + ttl: 10 * time.Second, + } +} + +// IsBlocked returns true if the raw API key matches any blocked pattern. +func (b *Blocklist) IsBlocked(rawKey string) bool { + b.mu.RLock() + patterns := b.patterns + lastLoad := b.lastLoad + b.mu.RUnlock() + + if time.Since(lastLoad) > b.ttl { + b.refresh() + b.mu.RLock() + patterns = b.patterns + b.mu.RUnlock() + } + + for _, p := range patterns { + if matched, _ := filepath.Match(p, rawKey); matched { + return true + } + } + return false +} + +// Refresh reloads blocked patterns from the store. +func (b *Blocklist) Refresh() { + b.refresh() +} + +func (b *Blocklist) refresh() { + var patterns []string + _ = b.store.GetJSON(context.Background(), "blocked_keys", &patterns) + b.mu.Lock() + b.patterns = patterns + b.lastLoad = time.Now() + b.mu.Unlock() +} diff --git a/internal/admin/handlers.go b/internal/admin/handlers.go index 85c2b5f..5f4d4f7 100644 --- a/internal/admin/handlers.go +++ b/internal/admin/handlers.go @@ -15,15 +15,17 @@ type Handler struct { ledger ledger.Ledger budgetMgr *budget.Manager token string // admin authentication token + blocklist *Blocklist } // NewHandler creates an admin API handler. -func NewHandler(store *Store, l ledger.Ledger, budgetMgr *budget.Manager, token string) *Handler { +func NewHandler(store *Store, l ledger.Ledger, budgetMgr *budget.Manager, token string, blocklist *Blocklist) *Handler { return &Handler{ store: store, ledger: l, budgetMgr: budgetMgr, token: token, + blocklist: blocklist, } } @@ -33,6 +35,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/admin/budgets/rules", h.requireAuth(h.handleCreateRule)) mux.HandleFunc("DELETE /api/admin/budgets/rules", h.requireAuth(h.handleDeleteRule)) mux.HandleFunc("GET /api/admin/api-keys", h.requireAuth(h.handleListAPIKeys)) + mux.HandleFunc("GET /api/admin/api-keys/blocked", h.requireAuth(h.handleListBlocked)) + mux.HandleFunc("POST /api/admin/api-keys/block", h.requireAuth(h.handleBlockKey)) + mux.HandleFunc("DELETE /api/admin/api-keys/block", h.requireAuth(h.handleUnblockKey)) mux.HandleFunc("GET /api/admin/providers", h.requireAuth(h.handleListProviders)) } @@ -126,6 +131,88 @@ func (h *Handler) handleDeleteRule(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// handleBlockKey adds an API key pattern to the blocklist. +func (h *Handler) handleBlockKey(w http.ResponseWriter, r *http.Request) { + var req struct { + Pattern string `json:"pattern"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid request: "+err.Error()) + return + } + if req.Pattern == "" { + writeAdminError(w, http.StatusBadRequest, "pattern is required") + return + } + + var patterns []string //nolint:prealloc + _ = h.store.GetJSON(r.Context(), "blocked_keys", &patterns) + patterns = append(patterns, req.Pattern) + + if err := h.store.SetJSON(r.Context(), "blocked_keys", patterns); err != nil { + writeAdminError(w, http.StatusInternalServerError, err.Error()) + return + } + + if h.blocklist != nil { + h.blocklist.Refresh() + } + + w.WriteHeader(http.StatusCreated) + writeAdminJSON(w, map[string]string{"pattern": req.Pattern}) +} + +// handleUnblockKey removes an API key pattern from the blocklist. +func (h *Handler) handleUnblockKey(w http.ResponseWriter, r *http.Request) { + pattern := r.URL.Query().Get("pattern") + if pattern == "" { + writeAdminError(w, http.StatusBadRequest, "pattern query parameter required") + return + } + + var patterns []string + _ = h.store.GetJSON(r.Context(), "blocked_keys", &patterns) + + var filtered []string + found := false + for _, p := range patterns { + if p == pattern { + found = true + continue + } + filtered = append(filtered, p) + } + + if !found { + writeAdminError(w, http.StatusNotFound, "pattern not found") + return + } + + if err := h.store.SetJSON(r.Context(), "blocked_keys", filtered); err != nil { + writeAdminError(w, http.StatusInternalServerError, err.Error()) + return + } + + if h.blocklist != nil { + h.blocklist.Refresh() + } + + w.WriteHeader(http.StatusNoContent) +} + +// handleListBlocked returns the list of blocked API key patterns. +func (h *Handler) handleListBlocked(w http.ResponseWriter, r *http.Request) { + var patterns []string + if err := h.store.GetJSON(r.Context(), "blocked_keys", &patterns); err != nil { + writeAdminError(w, http.StatusInternalServerError, err.Error()) + return + } + if patterns == nil { + patterns = []string{} + } + writeAdminJSON(w, patterns) +} + // handleListAPIKeys returns known API key hashes with their spend. func (h *Handler) handleListAPIKeys(w http.ResponseWriter, r *http.Request) { now := time.Now().UTC() diff --git a/internal/budget/budget.go b/internal/budget/budget.go index 4fb70b3..6beed50 100644 --- a/internal/budget/budget.go +++ b/internal/budget/budget.go @@ -30,11 +30,12 @@ type Result struct { // Rule defines budget limits for a set of API keys. type Rule struct { - APIKeyPattern string `mapstructure:"api_key_pattern"` - DailyLimitUSD float64 `mapstructure:"daily_limit_usd"` - MonthlyLimitUSD float64 `mapstructure:"monthly_limit_usd"` - SoftLimitPct float64 `mapstructure:"soft_limit_pct"` - Action string `mapstructure:"action"` + APIKeyPattern string `mapstructure:"api_key_pattern" json:"api_key_pattern"` + TenantID string `mapstructure:"tenant_id" json:"tenant_id,omitempty"` + DailyLimitUSD float64 `mapstructure:"daily_limit_usd" json:"daily_limit_usd"` + MonthlyLimitUSD float64 `mapstructure:"monthly_limit_usd" json:"monthly_limit_usd"` + SoftLimitPct float64 `mapstructure:"soft_limit_pct" json:"soft_limit_pct"` + Action string `mapstructure:"action" json:"action"` } // Config holds the default budget and per-key override rules. @@ -103,15 +104,63 @@ func (m *Manager) Enabled() bool { } // Check evaluates budget for a request. rawKey is used for rule pattern -// matching; apiKeyHash is used for spend lookups. -func (m *Manager) Check(ctx context.Context, rawKey, apiKeyHash string) Result { +// matching; apiKeyHash is used for spend lookups. tenantID is optional; +// when non-empty a tenant-scoped budget rule is also checked and the +// stricter result wins. +func (m *Manager) Check(ctx context.Context, rawKey, apiKeyHash, tenantID string) Result { rule := m.matchRule(rawKey) if rule.DailyLimitUSD <= 0 && rule.MonthlyLimitUSD <= 0 { - return Result{Decision: Allow} + // Even with no key-level limits, tenant limits may apply. + if tenantID == "" { + return Result{Decision: Allow} + } + tenantRule := m.matchTenantRule(tenantID) + if tenantRule == nil { + return Result{Decision: Allow} + } + tenantDaily, tenantMonthly := m.getTenantSpend(ctx, tenantID) + result := m.evaluateRule(*tenantRule, tenantDaily, tenantMonthly) + if result.Decision == Block && m.onBlock != nil { + m.onBlock(ctx, apiKeyHash, result) + } else if result.Decision == Warn && m.onWarn != nil { + m.onWarn(ctx, apiKeyHash, result) + } + return result } daily, monthly := m.getSpend(ctx, apiKeyHash) + result := m.evaluateRule(rule, daily, monthly) + + if result.Decision == Block && m.onBlock != nil { + m.onBlock(ctx, apiKeyHash, result) + } else if result.Decision == Warn && m.onWarn != nil { + m.onWarn(ctx, apiKeyHash, result) + } + + // Tenant-level budget check (if applicable). + if tenantID != "" { + tenantRule := m.matchTenantRule(tenantID) + if tenantRule != nil { + tenantDaily, tenantMonthly := m.getTenantSpend(ctx, tenantID) + tenantResult := m.evaluateRule(*tenantRule, tenantDaily, tenantMonthly) + // Take the stricter decision. + if tenantResult.Decision > result.Decision { + result = tenantResult + if result.Decision == Block && m.onBlock != nil { + m.onBlock(ctx, apiKeyHash, result) + } else if result.Decision == Warn && m.onWarn != nil { + m.onWarn(ctx, apiKeyHash, result) + } + } + } + } + + return result +} +// evaluateRule checks daily/monthly spend against a rule and returns the +// appropriate Result. It does not fire callbacks. +func (m *Manager) evaluateRule(rule Rule, daily, monthly float64) Result { result := Result{ Decision: Allow, DailySpent: daily, @@ -127,14 +176,8 @@ func (m *Manager) Check(ctx context.Context, rawKey, apiKeyHash string) Result { if exceeded { if rule.Action == "block" { result.Decision = Block - if m.onBlock != nil { - m.onBlock(ctx, apiKeyHash, result) - } - return result - } - result.Decision = Warn - if m.onWarn != nil { - m.onWarn(ctx, apiKeyHash, result) + } else { + result.Decision = Warn } return result } @@ -149,10 +192,6 @@ func (m *Manager) Check(ctx context.Context, rawKey, apiKeyHash string) Result { } } - if result.Decision == Warn && m.onWarn != nil { - m.onWarn(ctx, apiKeyHash, result) - } - return result } @@ -184,6 +223,51 @@ func (m *Manager) mergeWithDefault(r Rule) Rule { return r } +// matchTenantRule finds a rule that targets a specific tenant (no API key pattern). +func (m *Manager) matchTenantRule(tenantID string) *Rule { + for _, r := range m.config.Rules { + if r.TenantID == tenantID && r.APIKeyPattern == "" { + merged := m.mergeWithDefault(r) + return &merged + } + } + return nil +} + +// getTenantSpend returns daily and monthly spend for a tenant, using a short-lived cache. +func (m *Manager) getTenantSpend(ctx context.Context, tenantID string) (daily, monthly float64) { + cacheKey := "tenant:" + tenantID + if entry, ok := m.cache.Load(cacheKey); ok { + e := entry.(*spendEntry) + if time.Since(e.fetched) < m.cacheTTL { + return e.daily, e.monthly + } + } + + 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) + + var err error + daily, err = m.ledger.GetTotalSpendByTenant(ctx, tenantID, dayStart, now) + if err != nil { + m.logger.Error("budget: querying tenant daily spend", "error", err, "tenant_id", tenantID) + } + + monthly, err = m.ledger.GetTotalSpendByTenant(ctx, tenantID, monthStart, now) + if err != nil { + m.logger.Error("budget: querying tenant monthly spend", "error", err, "tenant_id", tenantID) + } + + m.cache.Store(cacheKey, &spendEntry{ + daily: daily, + monthly: monthly, + fetched: time.Now(), + }) + + return daily, monthly +} + // getSpend returns daily and monthly spend, using a short-lived cache. func (m *Manager) getSpend(ctx context.Context, apiKeyHash string) (daily, monthly float64) { if entry, ok := m.cache.Load(apiKeyHash); ok { diff --git a/internal/budget/budget_test.go b/internal/budget/budget_test.go index 1e66c52..e6191fb 100644 --- a/internal/budget/budget_test.go +++ b/internal/budget/budget_test.go @@ -47,7 +47,7 @@ func TestBudgetAllowWhenNoLimits(t *testing.T) { t.Error("should not be enabled with no limits") } - result := mgr.Check(context.Background(), "sk-test-key", "hash123") + result := mgr.Check(context.Background(), "sk-test-key", "hash123", "") if result.Decision != Allow { t.Errorf("expected Allow, got %d", result.Decision) } @@ -69,7 +69,7 @@ func TestBudgetAllowUnderLimit(t *testing.T) { t.Fatal("should be enabled") } - result := mgr.Check(context.Background(), "sk-test-key", "hash123") + result := mgr.Check(context.Background(), "sk-test-key", "hash123", "") if result.Decision != Allow { t.Errorf("expected Allow, got %d", result.Decision) } @@ -87,7 +87,7 @@ func TestBudgetWarnAtSoftLimit(t *testing.T) { } mgr := NewManager(store, cfg, newTestLogger()) - result := mgr.Check(context.Background(), "sk-test-key", "hash123") + result := mgr.Check(context.Background(), "sk-test-key", "hash123", "") if result.Decision != Warn { t.Errorf("expected Warn at 84%% daily, got %d", result.Decision) } @@ -105,7 +105,7 @@ func TestBudgetBlockAtHardLimit(t *testing.T) { } mgr := NewManager(store, cfg, newTestLogger()) - result := mgr.Check(context.Background(), "sk-test-key", "hash123") + result := mgr.Check(context.Background(), "sk-test-key", "hash123", "") if result.Decision != Block { t.Errorf("expected Block, got %d", result.Decision) } @@ -127,7 +127,7 @@ func TestBudgetWarnActionAtHardLimit(t *testing.T) { } mgr := NewManager(store, cfg, newTestLogger()) - result := mgr.Check(context.Background(), "sk-test-key", "hash123") + result := mgr.Check(context.Background(), "sk-test-key", "hash123", "") if result.Decision != Warn { t.Errorf("expected Warn (action=warn), got %d", result.Decision) } @@ -144,7 +144,7 @@ func TestBudgetMonthlyBlock(t *testing.T) { } mgr := NewManager(store, cfg, newTestLogger()) - result := mgr.Check(context.Background(), "sk-test-key", "hash123") + result := mgr.Check(context.Background(), "sk-test-key", "hash123", "") if result.Decision != Block { t.Errorf("expected Block on monthly limit, got %d", result.Decision) } @@ -168,13 +168,13 @@ func TestBudgetRulePatternMatch(t *testing.T) { mgr := NewManager(store, cfg, newTestLogger()) // Dev key should be blocked at 8.0 > 5.0. - result := mgr.Check(context.Background(), "sk-proj-dev-abc123", "hash-dev") + result := mgr.Check(context.Background(), "sk-proj-dev-abc123", "hash-dev", "") if result.Decision != Block { t.Errorf("expected Block for dev key, got %d", result.Decision) } // Non-dev key should be allowed at 8.0 < 50.0. - result = mgr.Check(context.Background(), "sk-proj-prod-xyz789", "hash-prod") + result = mgr.Check(context.Background(), "sk-proj-prod-xyz789", "hash-prod", "") if result.Decision != Allow { t.Errorf("expected Allow for prod key, got %d", result.Decision) } @@ -200,7 +200,7 @@ func TestBudgetRuleMergesDefaults(t *testing.T) { mgr := NewManager(store, cfg, newTestLogger()) // Should block because monthly 600 > default 500. - result := mgr.Check(context.Background(), "sk-proj-dev-abc", "hash-dev") + result := mgr.Check(context.Background(), "sk-proj-dev-abc", "hash-dev", "") if result.Decision != Block { t.Errorf("expected Block from inherited monthly limit, got %d", result.Decision) } @@ -217,11 +217,11 @@ func TestBudgetSpendCaching(t *testing.T) { mgr := NewManager(store, cfg, newTestLogger()) // First call populates cache. - mgr.Check(context.Background(), "sk-key", "hash1") + mgr.Check(context.Background(), "sk-key", "hash1", "") // Change underlying spend — cached value should be returned. store.dailySpend = 100.0 - result := mgr.Check(context.Background(), "sk-key", "hash1") + result := mgr.Check(context.Background(), "sk-key", "hash1", "") if result.Decision != Allow { t.Error("expected cached Allow, but got blocked from stale data") } diff --git a/internal/config/config.go b/internal/config/config.go index a9f6321..7dd05fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,9 +66,10 @@ type BudgetsConfig struct { Rules []BudgetRuleConfig `mapstructure:"rules"` } -// BudgetRuleConfig defines budget limits for a set of API keys. +// BudgetRuleConfig defines budget limits for a set of API keys or a tenant. type BudgetRuleConfig struct { APIKeyPattern string `mapstructure:"api_key_pattern"` + TenantID string `mapstructure:"tenant_id"` DailyLimitUSD float64 `mapstructure:"daily_limit_usd"` MonthlyLimitUSD float64 `mapstructure:"monthly_limit_usd"` SoftLimitPct float64 `mapstructure:"soft_limit_pct"` diff --git a/internal/dashboard/handlers.go b/internal/dashboard/handlers.go index 96978e0..8a5d816 100644 --- a/internal/dashboard/handlers.go +++ b/internal/dashboard/handlers.go @@ -1,7 +1,9 @@ package dashboard import ( + "encoding/csv" "encoding/json" + "fmt" "net/http" "strconv" "time" @@ -27,6 +29,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/dashboard/timeseries", h.handleTimeseries) mux.HandleFunc("GET /api/dashboard/costs", h.handleCosts) mux.HandleFunc("GET /api/dashboard/sessions", h.handleSessions) + mux.HandleFunc("GET /api/dashboard/export", h.handleExport) } func (h *Handler) handleSummary(w http.ResponseWriter, r *http.Request) { @@ -150,6 +153,75 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { writeJSON(w, sessions) } +func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) { + format := r.URL.Query().Get("format") + if format == "" { + format = "json" + } + + groupBy := r.URL.Query().Get("group_by") + if groupBy == "" { + groupBy = "model" + } + + hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) + if hours <= 0 { + hours = 720 // 30 days + } + + tenantID := r.URL.Query().Get("tenant") + + now := time.Now().UTC() + since := now.Add(-time.Duration(hours) * time.Hour) + + entries, err := h.ledger.QueryCosts(r.Context(), ledger.CostFilter{ + Since: since, + Until: now, + GroupBy: groupBy, + TenantID: tenantID, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + switch format { + case "csv": + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", `attachment; filename="agentledger-costs.csv"`) + + cw := csv.NewWriter(w) + defer cw.Flush() + + header := []string{ + "provider", "model", "api_key_hash", "agent_id", "session_id", + "requests", "input_tokens", "output_tokens", "cost_usd", + } + if err := cw.Write(header); err != nil { + return + } + + 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), + fmt.Sprintf("%.6f", e.TotalCostUSD), + } + if err := cw.Write(record); err != nil { + return + } + } + default: + writeJSON(w, entries) + } +} + func writeJSON(w http.ResponseWriter, data any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(data) diff --git a/internal/provider/openai_compat.go b/internal/provider/openai_compat.go index 9e4f116..668ccec 100644 --- a/internal/provider/openai_compat.go +++ b/internal/provider/openai_compat.go @@ -40,7 +40,8 @@ func (o *OpenAICompatible) Match(r *http.Request) bool { return strings.HasPrefix(p, prefix+"/v1/chat/") || strings.HasPrefix(p, prefix+"/v1/completions") || strings.HasPrefix(p, prefix+"/v1/embeddings") || - strings.HasPrefix(p, prefix+"/v1/models") + strings.HasPrefix(p, prefix+"/v1/models") || + strings.HasPrefix(p, prefix+"/v1/responses") } // RewritePath strips the provider path prefix so upstream sees the native path. @@ -52,10 +53,12 @@ func (o *OpenAICompatible) RewritePath(path string) string { } // openaiCompatRequest is the minimal subset of an OpenAI chat completion request. +// It also supports the Responses API which uses max_output_tokens instead of max_tokens. type openaiCompatRequest struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - Stream bool `json:"stream"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Stream bool `json:"stream"` } func (o *OpenAICompatible) ParseRequest(body []byte) (*RequestMeta, error) { @@ -63,19 +66,27 @@ func (o *OpenAICompatible) ParseRequest(body []byte) (*RequestMeta, error) { if err := json.Unmarshal(body, &req); err != nil { return nil, err } + maxTokens := req.MaxTokens + if req.MaxOutputTokens > 0 { + maxTokens = req.MaxOutputTokens + } return &RequestMeta{ Model: req.Model, - MaxTokens: req.MaxTokens, + MaxTokens: maxTokens, Stream: req.Stream, }, nil } // openaiCompatResponse is the minimal subset of an OpenAI chat completion response. +// It also supports the Responses API which uses input_tokens/output_tokens instead of +// prompt_tokens/completion_tokens. type openaiCompatResponse struct { Model string `json:"model"` Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` TotalTokens int `json:"total_tokens"` } `json:"usage"` } @@ -85,15 +96,25 @@ func (o *OpenAICompatible) ParseResponse(body []byte) (*ResponseMeta, error) { if err := json.Unmarshal(body, &resp); err != nil { return nil, err } + inputTokens := resp.Usage.PromptTokens + if resp.Usage.InputTokens > 0 { + inputTokens = resp.Usage.InputTokens + } + outputTokens := resp.Usage.CompletionTokens + if resp.Usage.OutputTokens > 0 { + outputTokens = resp.Usage.OutputTokens + } return &ResponseMeta{ Model: resp.Model, - InputTokens: resp.Usage.PromptTokens, - OutputTokens: resp.Usage.CompletionTokens, + InputTokens: inputTokens, + OutputTokens: outputTokens, TotalTokens: resp.Usage.TotalTokens, }, nil } // openaiCompatStreamChunk is the minimal subset of an OpenAI streaming chunk. +// It also supports the Responses API streaming format where usage appears in +// a "response.completed" event with a nested response object. type openaiCompatStreamChunk struct { Model string `json:"model"` Choices []struct { @@ -104,8 +125,20 @@ type openaiCompatStreamChunk struct { Usage *struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` TotalTokens int `json:"total_tokens"` } `json:"usage,omitempty"` + // Responses API fields + Type string `json:"type,omitempty"` + Response *struct { + Model string `json:"model"` + Usage *struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage,omitempty"` + } `json:"response,omitempty"` } func (o *OpenAICompatible) ParseStreamChunk(_ string, data []byte) (*StreamChunkMeta, error) { @@ -122,11 +155,30 @@ func (o *OpenAICompatible) ParseStreamChunk(_ string, data []byte) (*StreamChunk meta.Text = chunk.Choices[0].Delta.Content } + // Chat Completions usage (final chunk) if chunk.Usage != nil { meta.InputTokens = chunk.Usage.PromptTokens + if chunk.Usage.InputTokens > 0 { + meta.InputTokens = chunk.Usage.InputTokens + } meta.OutputTokens = chunk.Usage.CompletionTokens + if chunk.Usage.OutputTokens > 0 { + meta.OutputTokens = chunk.Usage.OutputTokens + } meta.Done = true } + // Responses API: response.completed event has usage + if chunk.Type == "response.completed" && chunk.Response != nil { + if chunk.Response.Model != "" { + meta.Model = chunk.Response.Model + } + if chunk.Response.Usage != nil { + meta.InputTokens = chunk.Response.Usage.InputTokens + meta.OutputTokens = chunk.Response.Usage.OutputTokens + meta.Done = true + } + } + return meta, nil } diff --git a/internal/provider/openai_compat_test.go b/internal/provider/openai_compat_test.go index ab66860..bf94f4f 100644 --- a/internal/provider/openai_compat_test.go +++ b/internal/provider/openai_compat_test.go @@ -28,6 +28,14 @@ func TestOpenAICompatMatch_NoPrefix(t *testing.T) { } } +func TestOpenAICompatMatch_ResponsesAPI(t *testing.T) { + o := NewOpenAI("https://api.openai.com") + r := &http.Request{URL: mustParseURL("/v1/responses"), Header: http.Header{}} + if !o.Match(r) { + t.Error("should match /v1/responses") + } +} + func TestOpenAICompatMatch_WithPrefix(t *testing.T) { g := NewGroq("") diff --git a/internal/provider/responses_test.go b/internal/provider/responses_test.go new file mode 100644 index 0000000..6e9a641 --- /dev/null +++ b/internal/provider/responses_test.go @@ -0,0 +1,75 @@ +package provider + +import "testing" + +func TestParseResponsesAPIRequest(t *testing.T) { + o := NewOpenAI("") + body := []byte(`{"model":"gpt-5","input":"hello","max_output_tokens":500,"stream":true}`) + meta, err := o.ParseRequest(body) + if err != nil { + t.Fatal(err) + } + if meta.Model != "gpt-5" { + t.Errorf("model = %q, want gpt-5", meta.Model) + } + if meta.MaxTokens != 500 { + t.Errorf("max_tokens = %d, want 500", meta.MaxTokens) + } + if !meta.Stream { + t.Error("stream = false, want true") + } +} + +func TestParseResponsesAPIResponse(t *testing.T) { + o := NewOpenAI("") + body := []byte(`{"model":"gpt-5","usage":{"input_tokens":200,"output_tokens":100,"total_tokens":300}}`) + meta, err := o.ParseResponse(body) + if err != nil { + t.Fatal(err) + } + if meta.InputTokens != 200 { + t.Errorf("input = %d, want 200", meta.InputTokens) + } + if meta.OutputTokens != 100 { + t.Errorf("output = %d, want 100", meta.OutputTokens) + } +} + +func TestParseResponsesAPIStreamChunk(t *testing.T) { + o := NewOpenAI("") + + // response.completed event + data := []byte(`{"type":"response.completed","response":{"model":"gpt-5","usage":{"input_tokens":150,"output_tokens":75,"total_tokens":225}}}`) + meta, err := o.ParseStreamChunk("", data) + if err != nil { + t.Fatal(err) + } + if meta.Model != "gpt-5" { + t.Errorf("model = %q, want gpt-5", meta.Model) + } + if meta.InputTokens != 150 { + t.Errorf("input = %d, want 150", meta.InputTokens) + } + if meta.OutputTokens != 75 { + t.Errorf("output = %d, want 75", meta.OutputTokens) + } + if !meta.Done { + t.Error("done = false, want true") + } +} + +func TestParseChatCompletionsStillWorks(t *testing.T) { + o := NewOpenAI("") + // Ensure traditional chat completions response still parses + body := []byte(`{"model":"gpt-4o","usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}`) + meta, err := o.ParseResponse(body) + if err != nil { + t.Fatal(err) + } + if meta.InputTokens != 100 { + t.Errorf("input = %d, want 100", meta.InputTokens) + } + if meta.OutputTokens != 50 { + t.Errorf("output = %d, want 50", meta.OutputTokens) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 8eda2f4..af9092e 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -15,6 +15,7 @@ import ( "github.com/oklog/ulid/v2" + "github.com/WDZ-Dev/agent-ledger/internal/admin" "github.com/WDZ-Dev/agent-ledger/internal/agent" "github.com/WDZ-Dev/agent-ledger/internal/budget" "github.com/WDZ-Dev/agent-ledger/internal/ledger" @@ -55,6 +56,7 @@ type Proxy struct { metrics *appmetrics.Metrics limiter *ratelimit.Limiter tenantResolver tenant.Resolver + blocklist *admin.Blocklist logger *slog.Logger } @@ -62,7 +64,7 @@ type Proxy struct { // optional budget manager, agent tracker, metrics, and transport. // Pass nil for budgetMgr/tracker/metrics to disable those features. // Pass nil for transport to use the default pooled transport. -func New(registry *provider.Registry, m *meter.Meter, recorder *ledger.Recorder, budgetMgr *budget.Manager, tracker *agent.Tracker, metrics *appmetrics.Metrics, limiter *ratelimit.Limiter, tenantRes tenant.Resolver, transport http.RoundTripper, logger *slog.Logger) *Proxy { +func New(registry *provider.Registry, m *meter.Meter, recorder *ledger.Recorder, budgetMgr *budget.Manager, tracker *agent.Tracker, metrics *appmetrics.Metrics, limiter *ratelimit.Limiter, tenantRes tenant.Resolver, blocklist *admin.Blocklist, transport http.RoundTripper, logger *slog.Logger) *Proxy { p := &Proxy{ registry: registry, meter: m, @@ -72,6 +74,7 @@ func New(registry *provider.Registry, m *meter.Meter, recorder *ledger.Recorder, metrics: metrics, limiter: limiter, tenantResolver: tenantRes, + blocklist: blocklist, logger: logger, } @@ -125,6 +128,12 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { apiKey := provider.ExtractAPIKey(r) apiKeyHash := provider.HashAPIKey(apiKey) + // Blocklist check: reject blocked API keys immediately. + if p.blocklist != nil && p.blocklist.IsBlocked(apiKey) { + writeJSONError(w, http.StatusForbidden, "API key is blocked") + return + } + // Extract agent headers before stripping. agentID, sessionID, userID, task := provider.ExtractAgentHeaders(r) sessionEnd := r.Header.Get("X-Agent-Session-End") == "true" @@ -159,10 +168,16 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + // Resolve tenant (if configured) — needed before budget check for tenant-scoped limits. + var tenantID string + if p.tenantResolver != nil { + tenantID = p.tenantResolver.ResolveTenant(r) + } + // Budget check: reject or warn before forwarding. var budgetResult *budget.Result if p.budget != nil && p.budget.Enabled() { - br := p.budget.Check(r.Context(), apiKey, apiKeyHash) + br := p.budget.Check(r.Context(), apiKey, apiKeyHash, tenantID) budgetResult = &br if br.Decision == budget.Block { p.logger.Warn("budget exceeded", @@ -198,12 +213,6 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - // Resolve tenant (if configured). - var tenantID string - if p.tenantResolver != nil { - tenantID = p.tenantResolver.ResolveTenant(r) - } - // Store everything in context for ModifyResponse. ctx := r.Context() ctx = context.WithValue(ctx, ctxProvider, prov) diff --git a/internal/proxy/proxy_bench_test.go b/internal/proxy/proxy_bench_test.go index c2b9543..5a32a16 100644 --- a/internal/proxy/proxy_bench_test.go +++ b/internal/proxy/proxy_bench_test.go @@ -34,7 +34,7 @@ func BenchmarkNonStreamingProxy(b *testing.B) { Anthropic: config.ProviderConfig{Upstream: upstream.URL, Enabled: true}, }) m := meter.New() - p := New(reg, m, rec, nil, nil, nil, nil, nil, nil, logger) + p := New(reg, m, rec, nil, nil, nil, nil, nil, nil, nil, logger) reqBody := `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}` @@ -70,7 +70,7 @@ func BenchmarkStreamingProxy(b *testing.B) { Anthropic: config.ProviderConfig{Upstream: upstream.URL, Enabled: true}, }) m := meter.New() - p := New(reg, m, rec, nil, nil, nil, nil, nil, nil, logger) + p := New(reg, m, rec, nil, nil, nil, nil, nil, nil, nil, logger) reqBody := `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"stream":true}` @@ -100,7 +100,7 @@ func BenchmarkHealthCheck(b *testing.B) { OpenAI: config.ProviderConfig{Upstream: upstream.URL, Enabled: true}, }) m := meter.New() - p := New(reg, m, rec, nil, nil, nil, nil, nil, nil, logger) + p := New(reg, m, rec, nil, nil, nil, nil, nil, nil, nil, logger) b.ResetTimer() b.ReportAllocs() diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index b94ef4b..2ff612d 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -68,7 +68,7 @@ func setupTestProxy(t *testing.T, upstream *httptest.Server) (*Proxy, *ledger.Re }) m := meter.New() - p := New(reg, m, rec, nil, nil, nil, nil, nil, nil, logger) + p := New(reg, m, rec, nil, nil, nil, nil, nil, nil, nil, logger) return p, rec, store } @@ -266,7 +266,7 @@ func TestBudgetBlocks429(t *testing.T) { }, }, logger) - p := New(reg, m, rec, budgetMgr, nil, nil, nil, nil, nil, logger) + p := New(reg, m, rec, budgetMgr, nil, nil, nil, nil, nil, nil, logger) body := `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}` req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)) @@ -315,7 +315,7 @@ func TestBudgetWarningHeader(t *testing.T) { }, }, logger) - p := New(reg, m, rec, budgetMgr, nil, nil, nil, nil, nil, logger) + p := New(reg, m, rec, budgetMgr, nil, nil, nil, nil, nil, nil, logger) body := `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}` req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)) @@ -360,7 +360,7 @@ func TestPreflightRejectsExpensiveRequest(t *testing.T) { }, }, logger) - p := New(reg, m, rec, budgetMgr, nil, nil, nil, nil, nil, logger) + p := New(reg, m, rec, budgetMgr, nil, nil, nil, nil, nil, nil, logger) // max_tokens=1000000 → worst-case output cost = $0.60 > $0.50 remaining body := `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"max_tokens":1000000}` @@ -423,7 +423,7 @@ func TestAgentLoopBlocks429(t *testing.T) { }, nil, logger) defer tracker.Close() - p := New(reg, m, rec, nil, tracker, nil, nil, nil, nil, logger) + p := New(reg, m, rec, nil, tracker, nil, nil, nil, nil, nil, logger) // Send 3 requests with the same session — third should trigger loop block. for i := 0; i < 3; i++ { @@ -468,7 +468,7 @@ func TestAgentSessionEndHeader(t *testing.T) { }, nil, logger) defer tracker.Close() - p := New(reg, m, rec, nil, tracker, nil, nil, nil, nil, logger) + p := New(reg, m, rec, nil, tracker, nil, nil, nil, nil, nil, logger) // Start a session. body := `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}` @@ -522,7 +522,7 @@ func TestPreflightAllowsCheapRequest(t *testing.T) { }, }, logger) - p := New(reg, m, rec, budgetMgr, nil, nil, nil, nil, nil, logger) + p := New(reg, m, rec, budgetMgr, nil, nil, nil, nil, nil, nil, logger) body := `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"max_tokens":100}` req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)) From 91c76ab19a0e92dfabc72519b4c745f65f446859 Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Mon, 16 Mar 2026 18:45:26 -0700 Subject: [PATCH 2/4] Fix goconst lint for postgres switch cases in export and costs commands --- cmd/agentledger/costs.go | 2 +- cmd/agentledger/export.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/agentledger/costs.go b/cmd/agentledger/costs.go index 652d02d..325fba9 100644 --- a/cmd/agentledger/costs.go +++ b/cmd/agentledger/costs.go @@ -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) diff --git a/cmd/agentledger/export.go b/cmd/agentledger/export.go index ce48ea5..bc028dd 100644 --- a/cmd/agentledger/export.go +++ b/cmd/agentledger/export.go @@ -49,7 +49,7 @@ func runExport(configPath, last, groupBy, tenant, format 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) From b832696987e01e056f7a93b03932a370bea69830 Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Mon, 16 Mar 2026 23:01:23 -0700 Subject: [PATCH 3/4] Add dashboard charts, expensive requests, error stats, and dark theme fixes - Add provider donut chart (Chart.js doughnut) with cost breakdown - Add most expensive requests table with /api/dashboard/expensive endpoint - Add error breakdown panel with /api/dashboard/stats endpoint - Add avg cost/request and error rate summary cards - Set Chart.js global default color for dark theme compatibility - Add QueryRecentExpensive and QueryErrorStats to Ledger interface - Implement new queries for SQLite and Postgres backends - Redesign dashboard layout with 2-column grid panels - Auto-select chart interval (hourly/daily/area) based on time range - Update all test mocks for new Ledger interface methods --- internal/budget/budget_test.go | 6 + internal/dashboard/handlers.go | 40 +++ internal/dashboard/handlers_test.go | 6 + internal/dashboard/static/app.js | 348 +++++++++++++++++++-------- internal/dashboard/static/index.html | 268 ++++++++++++++------- internal/dashboard/static/style.css | 175 +++++++++++--- internal/ledger/ledger.go | 6 + internal/ledger/models.go | 23 ++ internal/ledger/postgres.go | 67 +++++- internal/ledger/recorder_test.go | 12 + internal/ledger/sqlite.go | 71 +++++- internal/mcp/interceptor_test.go | 6 + internal/proxy/proxy_test.go | 8 + 13 files changed, 811 insertions(+), 225 deletions(-) diff --git a/internal/budget/budget_test.go b/internal/budget/budget_test.go index e6191fb..4793687 100644 --- a/internal/budget/budget_test.go +++ b/internal/budget/budget_test.go @@ -34,6 +34,12 @@ func (s *stubLedger) GetTotalSpendByTenant(_ context.Context, _ string, _, _ tim func (s *stubLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]ledger.TimeseriesPoint, error) { return nil, nil } +func (s *stubLedger) QueryRecentExpensive(_ context.Context, _, _ time.Time, _ string, _ int) ([]ledger.ExpensiveRequest, error) { + return nil, nil +} +func (s *stubLedger) QueryErrorStats(_ context.Context, _, _ time.Time, _ string) (*ledger.ErrorStats, error) { + return &ledger.ErrorStats{}, nil +} func (s *stubLedger) Close() error { return nil } func newTestLogger() *slog.Logger { diff --git a/internal/dashboard/handlers.go b/internal/dashboard/handlers.go index 8a5d816..e89afa5 100644 --- a/internal/dashboard/handlers.go +++ b/internal/dashboard/handlers.go @@ -30,6 +30,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/dashboard/costs", h.handleCosts) mux.HandleFunc("GET /api/dashboard/sessions", h.handleSessions) mux.HandleFunc("GET /api/dashboard/export", h.handleExport) + mux.HandleFunc("GET /api/dashboard/expensive", h.handleExpensive) + mux.HandleFunc("GET /api/dashboard/stats", h.handleStats) } func (h *Handler) handleSummary(w http.ResponseWriter, r *http.Request) { @@ -222,6 +224,44 @@ func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) handleExpensive(w http.ResponseWriter, r *http.Request) { + hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) + if hours <= 0 { + hours = 168 + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 10 + } + tenantID := r.URL.Query().Get("tenant") + now := time.Now().UTC() + since := now.Add(-time.Duration(hours) * time.Hour) + + results, err := h.ledger.QueryRecentExpensive(r.Context(), since, now, tenantID, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, results) +} + +func (h *Handler) handleStats(w http.ResponseWriter, r *http.Request) { + hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) + if hours <= 0 { + hours = 24 + } + tenantID := r.URL.Query().Get("tenant") + now := time.Now().UTC() + since := now.Add(-time.Duration(hours) * time.Hour) + + stats, err := h.ledger.QueryErrorStats(r.Context(), since, now, tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, stats) +} + func writeJSON(w http.ResponseWriter, data any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(data) diff --git a/internal/dashboard/handlers_test.go b/internal/dashboard/handlers_test.go index 28be7c0..ed0cbec 100644 --- a/internal/dashboard/handlers_test.go +++ b/internal/dashboard/handlers_test.go @@ -29,6 +29,12 @@ func (s *stubLedger) GetTotalSpendByTenant(_ context.Context, _ string, _, _ tim func (s *stubLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]ledger.TimeseriesPoint, error) { return s.timeseries, nil } +func (s *stubLedger) QueryRecentExpensive(_ context.Context, _, _ time.Time, _ string, _ int) ([]ledger.ExpensiveRequest, error) { + return nil, nil +} +func (s *stubLedger) QueryErrorStats(_ context.Context, _, _ time.Time, _ string) (*ledger.ErrorStats, error) { + return &ledger.ErrorStats{}, nil +} func (s *stubLedger) Close() error { return nil } func TestHandleSummary(t *testing.T) { diff --git a/internal/dashboard/static/app.js b/internal/dashboard/static/app.js index 443a854..19d4d87 100644 --- a/internal/dashboard/static/app.js +++ b/internal/dashboard/static/app.js @@ -1,8 +1,36 @@ (function () { "use strict"; + Chart.defaults.color = "#e1e4e8"; + const $ = (sel) => document.querySelector(sel); - const fmt = (n) => (n >= 1 ? n.toFixed(2) : n.toFixed(4)); + + function fmtCost(n) { + if (n >= 1000) return "$" + (n / 1000).toFixed(1) + "k"; + if (n >= 100) return "$" + n.toFixed(0); + if (n >= 1) return "$" + n.toFixed(2); + if (n >= 0.01) return "$" + n.toFixed(3); + return "$" + n.toFixed(4); + } + + function fmtAxis(n) { + if (n >= 1000) return "$" + (n / 1000).toFixed(1) + "k"; + if (n >= 100) return "$" + n.toFixed(0); + if (n >= 10) return "$" + n.toFixed(1); + return "$" + n.toFixed(2); + } + + function fmtPct(n) { + if (n === 0) return "0%"; + if (n < 0.001) return "<0.1%"; + return (n * 100).toFixed(1) + "%"; + } + + const DONUT_COLORS = [ + "#388bfd", "#3fb950", "#d29922", "#f85149", "#a371f7", + "#79c0ff", "#56d364", "#e3b341", "#ff7b72", "#bc8cff", + "#7ee787", "#ffa657", "#ff9bce", "#8b949e", + ]; // Tenant filtering let currentTenant = ""; @@ -37,12 +65,12 @@ return resp.json(); } - // Summary cards + // ── Summary cards ── async function loadSummary() { try { const d = await fetchJSON(tenantQS("/api/dashboard/summary")); - $("#today-spend").textContent = "$" + fmt(d.today_spend_usd); - $("#month-spend").textContent = "$" + fmt(d.month_spend_usd); + $("#today-spend").textContent = fmtCost(d.today_spend_usd); + $("#month-spend").textContent = fmtCost(d.month_spend_usd); $("#today-requests").textContent = d.today_requests.toLocaleString(); $("#active-sessions").textContent = d.active_sessions; } catch (e) { @@ -50,103 +78,194 @@ } } - // Timeseries chart (simple canvas bar chart — no external deps) + // ── Error stats + avg cost cards ── + async function loadStats() { + try { + const s = await fetchJSON(tenantQS("/api/dashboard/stats?hours=24")); + $("#error-rate").textContent = fmtPct(s.error_rate); + if (s.error_rate > 0.05) { + $("#error-rate").classList.add("card-value-error"); + } else { + $("#error-rate").classList.remove("card-value-error"); + } + $("#avg-cost").textContent = fmtCost(s.avg_cost_per_request); + + // Error breakdown panel + $("#stat-total").textContent = s.total_requests.toLocaleString(); + $("#stat-errors").textContent = s.error_requests.toLocaleString(); + $("#stat-429").textContent = s.count_429.toLocaleString(); + $("#stat-5xx").textContent = s.count_5xx.toLocaleString(); + $("#stat-latency").textContent = s.avg_duration_ms.toFixed(0) + "ms"; + $("#stat-avg-cost").textContent = fmtCost(s.avg_cost_per_request); + } catch (e) { + console.error("stats:", e); + } + } + + // ── Timeseries chart ── + let timeseriesChart = null; + + function formatLabel(ts, interval) { + const d = new Date(ts); + if (interval === "day") { + return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); + } + const now = new Date(); + if (d.toDateString() === now.toDateString()) { + return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + } + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) + + " " + d.toLocaleTimeString("en-US", { hour: "numeric" }); + } + async function loadTimeseries() { - const interval = $("#timeseries-interval").value; - const hours = $("#timeseries-hours").value; + const hours = parseInt($("#timeseries-hours").value); + const interval = hours <= 24 ? "hour" : "day"; + const useArea = hours > 168; + try { const points = await fetchJSON( tenantQS(`/api/dashboard/timeseries?interval=${interval}&hours=${hours}`) ); - drawBarChart( - "timeseries-chart", - points || [], - (p) => p.Timestamp.slice(5, 16), - (p) => p.CostUSD - ); + const data = points || []; + const labels = data.map((p) => formatLabel(p.Timestamp, interval)); + const values = data.map((p) => p.CostUSD); + const ctx = document.getElementById("timeseries-chart").getContext("2d"); + + if (timeseriesChart) timeseriesChart.destroy(); + + timeseriesChart = new Chart(ctx, { + type: useArea ? "line" : "bar", + data: { + labels, + datasets: [{ + label: "Cost (USD)", + data: values, + backgroundColor: useArea ? "rgba(56, 139, 253, 0.12)" : "rgba(56, 139, 253, 0.7)", + borderColor: "rgba(56, 139, 253, 1)", + borderWidth: useArea ? 2 : 0, + borderRadius: useArea ? 0 : 4, + maxBarThickness: 60, + fill: useArea, + tension: 0.35, + pointRadius: useArea ? 3 : 0, + pointBackgroundColor: "rgba(56, 139, 253, 1)", + pointBorderColor: "#161b22", + pointBorderWidth: 2, + pointHoverRadius: 6, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { intersect: false, mode: "index" }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "#1c2128", borderColor: "#30363d", borderWidth: 1, + titleColor: "#e1e4e8", bodyColor: "#c9d1d9", + titleFont: { size: 13 }, bodyFont: { size: 13 }, + padding: 12, cornerRadius: 8, + callbacks: { label: (ctx) => " " + fmtCost(ctx.parsed.y) }, + }, + }, + scales: { + x: { + grid: { color: "rgba(33,38,45,0.5)", drawBorder: false }, + ticks: { color: "#8b949e", font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: useArea ? 10 : 14 }, + }, + y: { + beginAtZero: true, + grid: { color: "rgba(33,38,45,0.5)", drawBorder: false }, + ticks: { color: "#8b949e", font: { size: 11 }, maxTicksLimit: 6, callback: (v) => fmtAxis(v) }, + }, + }, + }, + }); } catch (e) { console.error("timeseries:", e); } } - function drawBarChart(canvasId, data, labelFn, valueFn) { - const canvas = document.getElementById(canvasId); - const ctx = canvas.getContext("2d"); - const dpr = window.devicePixelRatio || 1; - const rect = canvas.parentElement.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); - - const w = rect.width; - const h = rect.height; - const pad = { top: 20, right: 20, bottom: 40, left: 60 }; - const cw = w - pad.left - pad.right; - const ch = h - pad.top - pad.bottom; - - ctx.clearRect(0, 0, w, h); - - if (!data.length) { - ctx.fillStyle = "#8b949e"; - ctx.font = "14px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText("No data", w / 2, h / 2); - return; - } + // ── Provider donut chart ── + let providerChart = null; - const values = data.map(valueFn); - const maxVal = Math.max(...values, 0.001); - - // Grid lines - ctx.strokeStyle = "#21262d"; - ctx.lineWidth = 1; - for (let i = 0; i <= 4; i++) { - const y = pad.top + (ch / 4) * i; - ctx.beginPath(); - ctx.moveTo(pad.left, y); - ctx.lineTo(pad.left + cw, y); - ctx.stroke(); - - ctx.fillStyle = "#8b949e"; - ctx.font = "11px sans-serif"; - ctx.textAlign = "right"; - const label = "$" + fmt(maxVal * (1 - i / 4)); - ctx.fillText(label, pad.left - 8, y + 4); - } + async function loadProviderChart() { + try { + const entries = await fetchJSON(tenantQS("/api/dashboard/costs?group_by=provider&hours=720")); + if (!entries || !entries.length) return; - // Bars - const barW = Math.max(2, (cw / data.length) * 0.7); - const gap = cw / data.length; - ctx.fillStyle = "#58a6ff"; - for (let i = 0; i < data.length; i++) { - const barH = (values[i] / maxVal) * ch; - const x = pad.left + gap * i + (gap - barW) / 2; - const y = pad.top + ch - barH; - ctx.fillRect(x, y, barW, barH); - } + const labels = entries.map((e) => e.Provider); + const values = entries.map((e) => e.TotalCostUSD); + const ctx = document.getElementById("provider-chart").getContext("2d"); + + if (providerChart) providerChart.destroy(); + + const capLabels = labels.map((l) => l.charAt(0).toUpperCase() + l.slice(1)); - // X-axis labels (show subset to avoid overlap) - ctx.fillStyle = "#8b949e"; - ctx.font = "10px sans-serif"; - ctx.textAlign = "center"; - const step = Math.max(1, Math.floor(data.length / 8)); - for (let i = 0; i < data.length; i += step) { - const x = pad.left + gap * i + gap / 2; - ctx.save(); - ctx.translate(x, pad.top + ch + 12); - ctx.rotate(-0.5); - ctx.fillText(labelFn(data[i]), 0, 0); - ctx.restore(); + providerChart = new Chart(ctx, { + type: "doughnut", + data: { + labels: capLabels, + datasets: [{ + data: values, + backgroundColor: DONUT_COLORS.slice(0, labels.length), + borderColor: "#161b22", + borderWidth: 2, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: "55%", + layout: { padding: 8 }, + plugins: { + legend: { + position: "bottom", + labels: { + color: "#e1e4e8", + font: { size: 13, weight: "500" }, + padding: 16, + usePointStyle: true, + pointStyleWidth: 10, + generateLabels: function (chart) { + const data = chart.data; + return data.labels.map((label, i) => ({ + text: label + " " + fmtCost(data.datasets[0].data[i]), + fillStyle: data.datasets[0].backgroundColor[i], + fontColor: "#e1e4e8", + strokeStyle: "transparent", + index: i, + pointStyle: "rectRounded", + })); + }, + }, + }, + tooltip: { + backgroundColor: "#1c2128", borderColor: "#30363d", borderWidth: 1, + titleColor: "#e1e4e8", bodyColor: "#c9d1d9", + padding: 12, cornerRadius: 8, + callbacks: { + label: function (ctx) { + const total = ctx.dataset.data.reduce((a, b) => a + b, 0); + const pct = ((ctx.parsed / total) * 100).toFixed(1); + return " " + fmtCost(ctx.parsed) + " (" + pct + "%)"; + }, + }, + }, + }, + }, + }); + } catch (e) { + console.error("provider chart:", e); } } - // Costs table + // ── Cost breakdown table ── async function loadCosts() { const groupBy = $("#costs-group").value; try { - const entries = await fetchJSON( - tenantQS(`/api/dashboard/costs?group_by=${groupBy}&hours=24`) - ); + const entries = await fetchJSON(tenantQS(`/api/dashboard/costs?group_by=${groupBy}&hours=168`)); const tbody = $("#costs-body"); tbody.innerHTML = ""; if (!entries || !entries.length) { @@ -162,10 +281,10 @@ const tr = document.createElement("tr"); tr.innerHTML = ` ${esc(name)} - ${e.Requests} + ${e.Requests.toLocaleString()} ${e.InputTokens.toLocaleString()} ${e.OutputTokens.toLocaleString()} - $${fmt(e.TotalCostUSD)} + ${fmtCost(e.TotalCostUSD)} `; tbody.appendChild(tr); } @@ -174,7 +293,37 @@ } } - // Sessions table + // ── Most expensive requests ── + async function loadExpensive() { + try { + const items = await fetchJSON(tenantQS("/api/dashboard/expensive?hours=168&limit=10")); + const tbody = $("#expensive-body"); + tbody.innerHTML = ""; + if (!items || !items.length) { + tbody.innerHTML = 'No data'; + return; + } + for (const r of items) { + const t = new Date(r.timestamp).toLocaleString("en-US", { + month: "short", day: "numeric", hour: "numeric", minute: "2-digit", + }); + const tokens = (r.input_tokens + r.output_tokens).toLocaleString(); + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${t} + ${esc(r.agent_id || "(none)")} + ${esc(r.model)} + ${tokens} + ${fmtCost(r.cost_usd)} + `; + tbody.appendChild(tr); + } + } catch (e) { + console.error("expensive:", e); + } + } + + // ── Sessions table ── async function loadSessions() { try { const sessions = await fetchJSON("/api/dashboard/sessions"); @@ -187,13 +336,15 @@ for (const s of sessions) { const tr = document.createElement("tr"); const statusClass = "status-" + s.Status; - const started = new Date(s.StartedAt).toLocaleTimeString(); + const started = new Date(s.StartedAt).toLocaleString("en-US", { + month: "short", day: "numeric", hour: "numeric", minute: "2-digit", + }); tr.innerHTML = ` - ${esc(s.ID.slice(0, 12))}... + ${esc(s.ID.slice(0, 12))} ${esc(s.AgentID || "(none)")} ${esc(s.UserID || "(none)")} ${s.CallCount} - $${fmt(s.TotalCostUSD)} + ${fmtCost(s.TotalCostUSD)} ${s.Status} ${started} `; @@ -210,7 +361,7 @@ return el.innerHTML; } - // API Keys table + // ── API Keys table ── async function loadAPIKeys() { try { const keys = await fetchJSON("/api/admin/api-keys"); @@ -222,13 +373,13 @@ } for (const k of keys) { const tr = document.createElement("tr"); - tr.innerHTML = `${esc(k.api_key_hash)}${k.requests}$${fmt(k.total_cost_usd)}`; + tr.innerHTML = `${esc(k.api_key_hash)}${k.requests}${fmtCost(k.total_cost_usd)}`; tbody.appendChild(tr); } } catch (e) { /* admin API may not be enabled */ } } - // Budget Rules table + // ── Budget Rules table ── async function loadRules() { try { const rules = await adminFetch("/api/admin/budgets/rules"); @@ -241,15 +392,14 @@ for (const r of rules) { const tr = document.createElement("tr"); tr.innerHTML = ` - ${esc(r.api_key_pattern || r.APIKeyPattern || "")} - ${r.daily_limit_usd || r.DailyLimitUSD || 0} - ${r.monthly_limit_usd || r.MonthlyLimitUSD || 0} + ${esc(r.api_key_pattern || r.APIKeyPattern || "")} + ${fmtCost(r.daily_limit_usd || r.DailyLimitUSD || 0)} + ${fmtCost(r.monthly_limit_usd || r.MonthlyLimitUSD || 0)} ${r.action || r.Action || ""} `; tbody.appendChild(tr); } - // Wire delete buttons for (const btn of tbody.querySelectorAll(".btn-delete")) { btn.addEventListener("click", async () => { await adminFetch("/api/admin/budgets/rules?pattern=" + encodeURIComponent(btn.dataset.pattern), { method: "DELETE" }); @@ -278,7 +428,7 @@ loadRules(); }); - // Tenant filter Apply button + // Tenant filter $("#apply-tenant").addEventListener("click", () => { currentTenant = $("#tenant-filter").value.trim(); loadAll(); @@ -286,19 +436,19 @@ function loadAll() { loadSummary(); + loadStats(); loadTimeseries(); + loadProviderChart(); loadCosts(); + loadExpensive(); loadSessions(); loadAPIKeys(); loadRules(); } - // Event listeners for controls - $("#timeseries-interval").addEventListener("change", loadTimeseries); $("#timeseries-hours").addEventListener("change", loadTimeseries); $("#costs-group").addEventListener("change", loadCosts); - // Initial load + auto-refresh every 30s loadAll(); setInterval(loadAll, 30000); })(); diff --git a/internal/dashboard/static/index.html b/internal/dashboard/static/index.html index 024bfc7..69d3ff9 100644 --- a/internal/dashboard/static/index.html +++ b/internal/dashboard/static/index.html @@ -5,11 +5,16 @@ AgentLedger Dashboard +
-

AgentLedger

-

Know what your agents cost.

+
+
+ + Know what your agents cost. +
+
@@ -23,6 +28,7 @@

AgentLedger

+
Today's Spend @@ -36,103 +42,187 @@

AgentLedger

Today's Requests --
+
+ Avg Cost / Request + -- +
Active Sessions --
-
- -
-
-

Cost Over Time

- - -
-
- +
+ Error Rate (24h) + --
-
-
-

Cost Breakdown

- -
- - - - - - - - - - - -
NameRequestsInput TokensOutput TokensCost (USD)
-
+ +
+
+
+

Cost Over Time

+ +
+
+ +
+
-
-

Active Sessions

- - - - - - - - - - - - - -
Session IDAgentUserCallsCost (USD)StatusStarted
-
+
+
+

Spend by Provider

+
+
+ +
+
+
-
-

API Keys (This Month)

- - - - - -
API Key HashRequestsCost (USD)
-
+ +
+
+
+

Cost Breakdown

+ +
+
+ + + + + + + + + + + +
NameRequestsInput TokensOutput TokensCost (USD)
+
+
-
-
-

Budget Rules

-
- - - - - -
API Key PatternDaily LimitMonthly LimitAction
-
- - - - - -
-
+
+
+

Most Expensive Requests

+
+
+ + + + + + + + + + + +
TimeAgentModelTokensCost
+
+
+
+ + +
+
+

Active Sessions

+
+ + + + + + + + + + + + + +
Session IDAgentUserCallsCost (USD)StatusStarted
+
+
+ +
+

Error Breakdown (24h)

+
+
+ Total Requests + -- +
+
+ Errors + -- +
+
+ Rate Limited (429) + -- +
+
+ Server Errors (5xx) + -- +
+
+ Avg Latency + -- +
+
+ Avg Cost / Request + -- +
+
+
+
+ + +
+
+

API Keys (This Month)

+
+ + + + + +
API Key HashRequestsCost (USD)
+
+
+ +
+
+

Budget Rules

+
+
+ + + + + +
API Key PatternDaily LimitMonthly LimitAction
+
+
+ + + + + +
+
+
diff --git a/internal/dashboard/static/style.css b/internal/dashboard/static/style.css index f61dc1a..2879521 100644 --- a/internal/dashboard/static/style.css +++ b/internal/dashboard/static/style.css @@ -2,45 +2,85 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #0f1117; + background: #0d1117; color: #e1e4e8; line-height: 1.6; } header { - padding: 1.5rem 2rem; + background: #161b22; border-bottom: 1px solid #21262d; + padding: 1rem 2rem; +} +.header-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; } -header h1 { font-size: 1.5rem; color: #58a6ff; } -header .tagline { font-size: 0.85rem; color: #8b949e; } +.header-title { + display: flex; + align-items: baseline; + gap: 1rem; +} +.logo { + font-size: 1.35rem; + font-weight: 700; + color: #f0f6fc; + letter-spacing: -0.02em; + font-family: "SF Mono", "Fira Code", "Cascadia Code", Menlo, monospace; +} +.tagline { font-size: 0.8rem; color: #8b949e; } main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; } +/* Summary cards */ .cards { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(6, 1fr); gap: 1rem; margin-bottom: 1.5rem; } +@media (max-width: 1200px) { + .cards { grid-template-columns: repeat(3, 1fr); } +} +@media (max-width: 768px) { + .cards { grid-template-columns: repeat(2, 1fr); } +} .card { background: #161b22; border: 1px solid #21262d; - border-radius: 8px; - padding: 1.25rem; + border-radius: 10px; + padding: 1.25rem 1.5rem; display: flex; flex-direction: column; + gap: 0.25rem; +} +.card-label { + font-size: 0.75rem; + color: #8b949e; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 500; +} +.card-value { + font-size: 1.75rem; + font-weight: 700; + color: #f0f6fc; + font-family: "SF Mono", "Fira Code", Menlo, monospace; } -.card-label { font-size: 0.8rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; } -.card-value { font-size: 1.75rem; font-weight: 600; color: #f0f6fc; margin-top: 0.25rem; } +.card-value-error { color: #f85149; } +/* Panels */ .panel { background: #161b22; border: 1px solid #21262d; - border-radius: 8px; - padding: 1.25rem; + border-radius: 10px; + padding: 1.25rem 1.5rem; margin-bottom: 1.5rem; } -.panel h2 { font-size: 1rem; color: #c9d1d9; } +.panel h2 { font-size: 0.95rem; font-weight: 600; color: #e1e4e8; } .panel-header { display: flex; align-items: center; @@ -56,19 +96,86 @@ main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; } font-size: 0.8rem; } -.chart-container { position: relative; height: 250px; } -canvas { width: 100% !important; height: 100% !important; } +/* 2-column grid layout */ +.grid-2col { + display: grid; + grid-template-columns: 3fr 2fr; + gap: 1.5rem; + margin-bottom: 1.5rem; +} +.grid-2col > .panel { margin-bottom: 0; } +@media (max-width: 1024px) { + .grid-2col { grid-template-columns: 1fr; } +} + +/* Chart */ +.chart-container { + position: relative; + height: 280px; +} +.chart-container-donut { + position: relative; + height: 320px; + max-width: 500px; + margin: 0 auto; +} + +/* Table scroll */ +.table-scroll { + max-height: 350px; + overflow-y: auto; +} + +/* Stats grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin-top: 0.75rem; +} +.stat-item { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.75rem; + background: #0d1117; + border: 1px solid #21262d; + border-radius: 8px; +} +.stat-label { + font-size: 0.7rem; + color: #8b949e; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.stat-value { + font-size: 1.25rem; + font-weight: 700; + color: #f0f6fc; + font-family: "SF Mono", "Fira Code", Menlo, monospace; +} +.stat-error { color: #f85149; } +.stat-warn { color: #d29922; } +/* Tables */ table { width: 100%; border-collapse: collapse; margin-top: 0.75rem; } -th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #21262d; font-size: 0.85rem; } -th { color: #8b949e; font-weight: 500; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.05em; } +th, td { text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid #21262d; font-size: 0.85rem; } +th { + color: #8b949e; + font-weight: 500; + text-transform: uppercase; + font-size: 0.7rem; + letter-spacing: 0.06em; +} td { color: #c9d1d9; } -tr:hover td { background: #1c2128; } +td:last-child, th:last-child { text-align: right; } +tr:hover td { background: rgba(56, 139, 253, 0.04); } -.status-active { color: #3fb950; } +.status-active { color: #3fb950; font-weight: 500; } .status-completed { color: #8b949e; } -.status-killed { color: #f85149; } +.status-killed { color: #f85149; font-weight: 500; } +/* Filter bar */ .filter-bar { display: flex; align-items: center; @@ -77,7 +184,7 @@ tr:hover td { background: #1c2128; } padding: 0.75rem 1rem; background: #161b22; border: 1px solid #21262d; - border-radius: 8px; + border-radius: 10px; } .filter-bar label { font-size: 0.8rem; color: #8b949e; } .filter-bar input[type="text"], .filter-bar input[type="password"] { @@ -85,7 +192,7 @@ tr:hover td { background: #1c2128; } color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; - padding: 0.35rem 0.5rem; + padding: 0.4rem 0.6rem; font-size: 0.8rem; width: 150px; } @@ -94,13 +201,16 @@ tr:hover td { background: #1c2128; } color: #fff; border: none; border-radius: 6px; - padding: 0.35rem 0.75rem; + padding: 0.4rem 0.85rem; font-size: 0.8rem; + font-weight: 500; cursor: pointer; + transition: background 0.15s; } .filter-bar button:hover, .add-rule-form button:hover { background: #2ea043; } .filter-sep { width: 1px; height: 1.5rem; background: #30363d; } +/* Add rule form */ .add-rule-form { display: flex; gap: 0.5rem; @@ -113,21 +223,24 @@ tr:hover td { background: #1c2128; } color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; - padding: 0.35rem 0.5rem; + padding: 0.4rem 0.6rem; font-size: 0.8rem; } .add-rule-form input[type="text"] { width: 150px; } -.add-rule-form input[type="number"] { width: 100px; } +.add-rule-form input[type="number"] { width: 120px; -moz-appearance: textfield; } +.add-rule-form input[type="number"]::-webkit-inner-spin-button, +.add-rule-form input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .btn-delete { - background: #da3633; - color: #fff; - border: none; - border-radius: 4px; - width: 24px; - height: 24px; + background: transparent; + color: #f85149; + border: 1px solid #f8514933; + border-radius: 6px; + width: 28px; + height: 28px; cursor: pointer; font-size: 1rem; line-height: 1; + transition: all 0.15s; } -.btn-delete:hover { background: #f85149; } +.btn-delete:hover { background: #f8514922; border-color: #f85149; } diff --git a/internal/ledger/ledger.go b/internal/ledger/ledger.go index e5eb989..92e3701 100644 --- a/internal/ledger/ledger.go +++ b/internal/ledger/ledger.go @@ -25,6 +25,12 @@ type Ledger interface { // 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) + // QueryRecentExpensive returns the N most expensive individual requests in the time window. + QueryRecentExpensive(ctx context.Context, since, until time.Time, tenantID string, limit int) ([]ExpensiveRequest, error) + + // QueryErrorStats returns error counts and average metrics for the time window. + QueryErrorStats(ctx context.Context, since, until time.Time, tenantID string) (*ErrorStats, error) + // Close releases any held resources. Close() error } diff --git a/internal/ledger/models.go b/internal/ledger/models.go index 462049b..b93039f 100644 --- a/internal/ledger/models.go +++ b/internal/ledger/models.go @@ -50,3 +50,26 @@ type CostEntry struct { OutputTokens int64 TotalCostUSD float64 } + +// ExpensiveRequest is a single high-cost request for the "top expensive" view. +type ExpensiveRequest struct { + Timestamp time.Time `json:"timestamp"` + Provider string `json:"provider"` + Model string `json:"model"` + AgentID string `json:"agent_id"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CostUSD float64 `json:"cost_usd"` + DurationMS int64 `json:"duration_ms"` +} + +// ErrorStats contains error rate information for a time window. +type ErrorStats struct { + TotalRequests int `json:"total_requests"` + ErrorRequests int `json:"error_requests"` + ErrorRate float64 `json:"error_rate"` + Count429 int `json:"count_429"` + Count5xx int `json:"count_5xx"` + AvgDurationMS float64 `json:"avg_duration_ms"` + AvgCostPerReq float64 `json:"avg_cost_per_request"` +} diff --git a/internal/ledger/postgres.go b/internal/ledger/postgres.go index 7a423f0..b9653c6 100644 --- a/internal/ledger/postgres.go +++ b/internal/ledger/postgres.go @@ -91,7 +91,7 @@ func (p *Postgres) QueryCosts(ctx context.Context, filter CostFilter) ([]CostEnt groupCol = "session_id" } - where := "timestamp >= $1 AND timestamp <= $2" + where := "timestamp >= $1 AND timestamp <= $2" //nolint:goconst args := []any{filter.Since.UTC(), filter.Until.UTC()} if filter.TenantID != "" { args = append(args, filter.TenantID) @@ -133,7 +133,7 @@ func (p *Postgres) QueryCostTimeseries(ctx context.Context, interval string, sin bucket = "date_trunc('day', timestamp)" } - where := "timestamp >= $1 AND timestamp <= $2" + where := "timestamp >= $1 AND timestamp <= $2" //nolint:goconst args := []any{since.UTC(), until.UTC()} if tenantID != "" { args = append(args, tenantID) @@ -188,6 +188,69 @@ func (p *Postgres) GetTotalSpendByTenant(ctx context.Context, tenantID string, s return total, nil } +func (p *Postgres) QueryRecentExpensive(ctx context.Context, since, until time.Time, tenantID string, limit int) ([]ExpensiveRequest, error) { + where := "timestamp >= $1 AND timestamp <= $2" //nolint:goconst //nolint:goconst + args := []any{since.UTC(), until.UTC()} + if tenantID != "" { + args = append(args, tenantID) + where += fmt.Sprintf(" AND tenant_id = $%d", len(args)) + } + args = append(args, limit) + + q := fmt.Sprintf(`SELECT timestamp, provider, model, agent_id, `+ //nolint:gosec // where clause is built from trusted code, not user input + `input_tokens, output_tokens, cost_usd, duration_ms + FROM usage_records WHERE %s + ORDER BY cost_usd DESC LIMIT $%d`, where, len(args)) + + rows, err := p.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("querying expensive requests: %w", err) + } + defer func() { _ = rows.Close() }() + + var results []ExpensiveRequest + for rows.Next() { + var r ExpensiveRequest + if err := rows.Scan(&r.Timestamp, &r.Provider, &r.Model, &r.AgentID, + &r.InputTokens, &r.OutputTokens, &r.CostUSD, &r.DurationMS); err != nil { + return nil, fmt.Errorf("scanning expensive request: %w", err) + } + results = append(results, r) + } + return results, rows.Err() +} + +func (p *Postgres) QueryErrorStats(ctx context.Context, since, until time.Time, tenantID string) (*ErrorStats, error) { + where := "timestamp >= $1 AND timestamp <= $2" //nolint:goconst //nolint:goconst + 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 + COUNT(*), + SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END), + SUM(CASE WHEN status_code = 429 THEN 1 ELSE 0 END), + SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END), + COALESCE(AVG(duration_ms), 0), + COALESCE(AVG(cost_usd), 0) + FROM usage_records WHERE %s`, where) + + var stats ErrorStats + if err := p.db.QueryRowContext(ctx, q, args...).Scan( + &stats.TotalRequests, &stats.ErrorRequests, + &stats.Count429, &stats.Count5xx, + &stats.AvgDurationMS, &stats.AvgCostPerReq, + ); err != nil { + return nil, fmt.Errorf("querying error stats: %w", err) + } + if stats.TotalRequests > 0 { + stats.ErrorRate = float64(stats.ErrorRequests) / float64(stats.TotalRequests) + } + return &stats, nil +} + // UpsertSession inserts or updates an agent session record. func (p *Postgres) UpsertSession(ctx context.Context, sess *agent.Session) error { const q = `INSERT INTO agent_sessions ( diff --git a/internal/ledger/recorder_test.go b/internal/ledger/recorder_test.go index b9307cb..1ef4493 100644 --- a/internal/ledger/recorder_test.go +++ b/internal/ledger/recorder_test.go @@ -33,6 +33,12 @@ func (c *countingLedger) GetTotalSpendByTenant(_ context.Context, _ string, _, _ func (c *countingLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]TimeseriesPoint, error) { return nil, nil } +func (c *countingLedger) QueryRecentExpensive(_ context.Context, _, _ time.Time, _ string, _ int) ([]ExpensiveRequest, error) { + return nil, nil +} +func (c *countingLedger) QueryErrorStats(_ context.Context, _, _ time.Time, _ string) (*ErrorStats, error) { + return &ErrorStats{}, nil +} func (c *countingLedger) Close() error { return nil } @@ -56,6 +62,12 @@ func (f *failingLedger) GetTotalSpendByTenant(_ context.Context, _ string, _, _ func (f *failingLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]TimeseriesPoint, error) { return nil, nil } +func (f *failingLedger) QueryRecentExpensive(_ context.Context, _, _ time.Time, _ string, _ int) ([]ExpensiveRequest, error) { + return nil, nil +} +func (f *failingLedger) QueryErrorStats(_ context.Context, _, _ time.Time, _ string) (*ErrorStats, error) { + return &ErrorStats{}, nil +} func (f *failingLedger) Close() error { return nil } diff --git a/internal/ledger/sqlite.go b/internal/ledger/sqlite.go index 4e810d1..7e6d241 100644 --- a/internal/ledger/sqlite.go +++ b/internal/ledger/sqlite.go @@ -93,10 +93,10 @@ func (s *SQLite) QueryCosts(ctx context.Context, filter CostFilter) ([]CostEntry groupCol = "session_id" } - where := "timestamp >= ? AND timestamp <= ?" + where := "timestamp >= ? AND timestamp <= ?" //nolint:goconst args := []any{filter.Since.UTC(), filter.Until.UTC()} if filter.TenantID != "" { - where += " AND tenant_id = ?" + where += " AND tenant_id = ?" //nolint:goconst args = append(args, filter.TenantID) } @@ -137,10 +137,10 @@ func (s *SQLite) QueryCostTimeseries(ctx context.Context, interval string, since bucket = "strftime('%Y-%m-%d 00:00:00', substr(timestamp, 1, 19))" } - where := "timestamp >= ? AND timestamp <= ?" + where := "timestamp >= ? AND timestamp <= ?" //nolint:goconst args := []any{since.UTC(), until.UTC()} if tenantID != "" { - where += " AND tenant_id = ?" + where += " AND tenant_id = ?" //nolint:goconst args = append(args, tenantID) } @@ -194,6 +194,69 @@ func (s *SQLite) GetTotalSpendByTenant(ctx context.Context, tenantID string, sin return total, nil } +func (s *SQLite) QueryRecentExpensive(ctx context.Context, since, until time.Time, tenantID string, limit int) ([]ExpensiveRequest, error) { + where := "timestamp >= ? AND timestamp <= ?" //nolint:goconst //nolint:goconst + args := []any{since.UTC(), until.UTC()} + if tenantID != "" { + where += " AND tenant_id = ?" //nolint:goconst //nolint:goconst + args = append(args, tenantID) + } + args = append(args, limit) + + q := fmt.Sprintf(`SELECT timestamp, provider, model, agent_id, + input_tokens, output_tokens, cost_usd, duration_ms + FROM usage_records WHERE %s + ORDER BY cost_usd DESC LIMIT ?`, where) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("querying expensive requests: %w", err) + } + defer func() { _ = rows.Close() }() + + var results []ExpensiveRequest + for rows.Next() { + var r ExpensiveRequest + if err := rows.Scan(&r.Timestamp, &r.Provider, &r.Model, &r.AgentID, + &r.InputTokens, &r.OutputTokens, &r.CostUSD, &r.DurationMS); err != nil { + return nil, fmt.Errorf("scanning expensive request: %w", err) + } + results = append(results, r) + } + return results, rows.Err() +} + +func (s *SQLite) QueryErrorStats(ctx context.Context, since, until time.Time, tenantID string) (*ErrorStats, error) { + where := "timestamp >= ? AND timestamp <= ?" //nolint:goconst //nolint:goconst + args := []any{since.UTC(), until.UTC()} + if tenantID != "" { + where += " AND tenant_id = ?" //nolint:goconst //nolint:goconst + args = append(args, tenantID) + } + + q := fmt.Sprintf(`SELECT + COUNT(*), + SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END), + SUM(CASE WHEN status_code = 429 THEN 1 ELSE 0 END), + SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END), + COALESCE(AVG(duration_ms), 0), + COALESCE(AVG(cost_usd), 0) + FROM usage_records WHERE %s`, where) + + var stats ErrorStats + if err := s.db.QueryRowContext(ctx, q, args...).Scan( + &stats.TotalRequests, &stats.ErrorRequests, + &stats.Count429, &stats.Count5xx, + &stats.AvgDurationMS, &stats.AvgCostPerReq, + ); err != nil { + return nil, fmt.Errorf("querying error stats: %w", err) + } + if stats.TotalRequests > 0 { + stats.ErrorRate = float64(stats.ErrorRequests) / float64(stats.TotalRequests) + } + return &stats, nil +} + // UpsertSession inserts or updates an agent session record. func (s *SQLite) UpsertSession(ctx context.Context, sess *agent.Session) error { const q = `INSERT INTO agent_sessions ( diff --git a/internal/mcp/interceptor_test.go b/internal/mcp/interceptor_test.go index e15abf8..5fdd721 100644 --- a/internal/mcp/interceptor_test.go +++ b/internal/mcp/interceptor_test.go @@ -41,6 +41,12 @@ func (r *recordingLedger) GetTotalSpendByTenant(_ context.Context, _ string, _, func (r *recordingLedger) QueryCostTimeseries(_ context.Context, _ string, _, _ time.Time, _ string) ([]ledger.TimeseriesPoint, error) { return nil, nil } +func (r *recordingLedger) QueryRecentExpensive(_ context.Context, _, _ time.Time, _ string, _ int) ([]ledger.ExpensiveRequest, error) { + return nil, nil +} +func (r *recordingLedger) QueryErrorStats(_ context.Context, _, _ time.Time, _ string) (*ledger.ErrorStats, error) { + return &ledger.ErrorStats{}, nil +} func (r *recordingLedger) Close() error { return nil } diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 2ff612d..a38f4ed 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -47,6 +47,14 @@ func (m *mockStore) QueryCostTimeseries(_ context.Context, _ string, _, _ time.T return nil, nil } +func (m *mockStore) QueryRecentExpensive(_ context.Context, _, _ time.Time, _ string, _ int) ([]ledger.ExpensiveRequest, error) { + return nil, nil +} + +func (m *mockStore) QueryErrorStats(_ context.Context, _, _ time.Time, _ string) (*ledger.ErrorStats, error) { + return &ledger.ErrorStats{}, nil +} + func (m *mockStore) Close() error { return nil } func setupTestProxy(t *testing.T, upstream *httptest.Server) (*Proxy, *ledger.Recorder, *mockStore) { From 92fe7514675317b17b854a4dec2ecc39919eb8cf Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Mon, 16 Mar 2026 23:36:15 -0700 Subject: [PATCH 4/4] Add minute-level timeseries and unify chart to line/area format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 30min and 1hr options to timeseries dropdown - Add minute-level bucketing to SQLite and Postgres backends - Support fractional hours in timeseries handler - Switch all time ranges to line/area chart (no more bar charts) - Auto-select interval: minute for ≤6h, hourly for ≤24h, daily for >24h --- internal/dashboard/handlers.go | 8 ++++---- internal/dashboard/static/app.js | 24 +++++++++++++----------- internal/dashboard/static/index.html | 3 +++ internal/ledger/ledger.go | 2 +- internal/ledger/postgres.go | 5 ++++- internal/ledger/sqlite.go | 5 ++++- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/internal/dashboard/handlers.go b/internal/dashboard/handlers.go index e89afa5..f0a37b0 100644 --- a/internal/dashboard/handlers.go +++ b/internal/dashboard/handlers.go @@ -96,15 +96,15 @@ func (h *Handler) handleTimeseries(w http.ResponseWriter, r *http.Request) { interval = "hour" } - hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) - if hours <= 0 { - hours = 24 + hoursF, _ := strconv.ParseFloat(r.URL.Query().Get("hours"), 64) + if hoursF <= 0 { + hoursF = 24 } tenantID := r.URL.Query().Get("tenant") now := time.Now().UTC() - since := now.Add(-time.Duration(hours) * time.Hour) + since := now.Add(-time.Duration(hoursF * float64(time.Hour))) points, err := h.ledger.QueryCostTimeseries(r.Context(), interval, since, now, tenantID) if err != nil { diff --git a/internal/dashboard/static/app.js b/internal/dashboard/static/app.js index 19d4d87..fc46906 100644 --- a/internal/dashboard/static/app.js +++ b/internal/dashboard/static/app.js @@ -110,6 +110,9 @@ if (interval === "day") { return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); } + if (interval === "minute") { + return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + } const now = new Date(); if (d.toDateString() === now.toDateString()) { return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); @@ -119,9 +122,10 @@ } async function loadTimeseries() { - const hours = parseInt($("#timeseries-hours").value); - const interval = hours <= 24 ? "hour" : "day"; - const useArea = hours > 168; + const hours = parseFloat($("#timeseries-hours").value); + let interval = "hour"; + if (hours <= 6) interval = "minute"; + else if (hours > 24) interval = "day"; try { const points = await fetchJSON( @@ -135,20 +139,18 @@ if (timeseriesChart) timeseriesChart.destroy(); timeseriesChart = new Chart(ctx, { - type: useArea ? "line" : "bar", + type: "line", data: { labels, datasets: [{ label: "Cost (USD)", data: values, - backgroundColor: useArea ? "rgba(56, 139, 253, 0.12)" : "rgba(56, 139, 253, 0.7)", + backgroundColor: "rgba(56, 139, 253, 0.12)", borderColor: "rgba(56, 139, 253, 1)", - borderWidth: useArea ? 2 : 0, - borderRadius: useArea ? 0 : 4, - maxBarThickness: 60, - fill: useArea, + borderWidth: 2, + fill: true, tension: 0.35, - pointRadius: useArea ? 3 : 0, + pointRadius: data.length > 60 ? 0 : 3, pointBackgroundColor: "rgba(56, 139, 253, 1)", pointBorderColor: "#161b22", pointBorderWidth: 2, @@ -172,7 +174,7 @@ scales: { x: { grid: { color: "rgba(33,38,45,0.5)", drawBorder: false }, - ticks: { color: "#8b949e", font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: useArea ? 10 : 14 }, + ticks: { color: "#8b949e", font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 }, }, y: { beginAtZero: true, diff --git a/internal/dashboard/static/index.html b/internal/dashboard/static/index.html index 69d3ff9..fdefbeb 100644 --- a/internal/dashboard/static/index.html +++ b/internal/dashboard/static/index.html @@ -62,6 +62,9 @@

Cost Over Time