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
new file mode 100644
index 0000000..bc028dd
--- /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": //nolint:goconst
+ store, err = ledger.NewPostgres(cfg.Storage.DSN, cfg.Storage.MaxOpenConns, cfg.Storage.MaxIdleConns)
+ default:
+ store, err = ledger.NewSQLite(cfg.Storage.DSN)
+ }
+ if err != nil {
+ return err
+ }
+ defer func() { _ = store.Close() }()
+
+ window, err := parseDuration(last)
+ if err != nil {
+ return fmt.Errorf("invalid --last value %q: %w", last, err)
+ }
+
+ now := time.Now()
+ filter := ledger.CostFilter{
+ Since: now.Add(-window),
+ Until: now,
+ GroupBy: groupBy,
+ TenantID: tenant,
+ }
+
+ entries, err := store.QueryCosts(context.Background(), filter)
+ if err != nil {
+ return err
+ }
+
+ switch format {
+ case "json":
+ return json.NewEncoder(os.Stdout).Encode(entries)
+ case "csv":
+ return writeCSV(os.Stdout, entries)
+ default:
+ return fmt.Errorf("unsupported format %q: use csv or json", format)
+ }
+}
+
+func writeCSV(out *os.File, entries []ledger.CostEntry) error {
+ w := csv.NewWriter(out)
+ defer w.Flush()
+
+ header := []string{
+ "provider", "model", "api_key_hash", "agent_id", "session_id",
+ "requests", "input_tokens", "output_tokens", "cost_usd",
+ }
+ if err := w.Write(header); err != nil {
+ return err
+ }
+
+ for _, e := range entries {
+ record := []string{
+ e.Provider,
+ e.Model,
+ e.APIKeyHash,
+ e.AgentID,
+ e.SessionID,
+ strconv.Itoa(e.Requests),
+ strconv.FormatInt(e.InputTokens, 10),
+ strconv.FormatInt(e.OutputTokens, 10),
+ strconv.FormatFloat(e.TotalCostUSD, 'f', 6, 64),
+ }
+ if err := w.Write(record); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
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..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 {
@@ -47,7 +53,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 +75,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 +93,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 +111,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 +133,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 +150,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 +174,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 +206,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 +223,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..f0a37b0 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,9 @@ 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)
+ 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) {
@@ -91,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 {
@@ -150,6 +155,113 @@ 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 (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..fc46906 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,196 @@
}
}
- // 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" });
+ }
+ 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" });
+ }
+ 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 = parseFloat($("#timeseries-hours").value);
+ let interval = "hour";
+ if (hours <= 6) interval = "minute";
+ else if (hours > 24) interval = "day";
+
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: "line",
+ data: {
+ labels,
+ datasets: [{
+ label: "Cost (USD)",
+ data: values,
+ backgroundColor: "rgba(56, 139, 253, 0.12)",
+ borderColor: "rgba(56, 139, 253, 1)",
+ borderWidth: 2,
+ fill: true,
+ tension: 0.35,
+ pointRadius: data.length > 60 ? 0 : 3,
+ 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: 10 },
+ },
+ 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 +283,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 +295,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 +338,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 +363,7 @@
return el.innerHTML;
}
- // API Keys table
+ // ── API Keys table ──
async function loadAPIKeys() {
try {
const keys = await fetchJSON("/api/admin/api-keys");
@@ -222,13 +375,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 +394,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 +430,7 @@
loadRules();
});
- // Tenant filter Apply button
+ // Tenant filter
$("#apply-tenant").addEventListener("click", () => {
currentTenant = $("#tenant-filter").value.trim();
loadAll();
@@ -286,19 +438,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..fdefbeb 100644
--- a/internal/dashboard/static/index.html
+++ b/internal/dashboard/static/index.html
@@ -5,11 +5,16 @@
AgentLedger Dashboard
+
@@ -23,6 +28,7 @@ AgentLedger
+
Today's Spend
@@ -36,103 +42,190 @@
AgentLedger
Today's Requests
--
+
+ Avg Cost / Request
+ --
+
Active Sessions
--
-
-
-
-
-
-
+
+ Error Rate (24h)
+ --
-
-
-
-
-
- | Name |
- Requests |
- Input Tokens |
- Output Tokens |
- Cost (USD) |
-
-
-
-
-
+
+
+
-
- Active Sessions
-
-
-
- | Session ID |
- Agent |
- User |
- Calls |
- Cost (USD) |
- Status |
- Started |
-
-
-
-
-
+
+
-
- API Keys (This Month)
-
-
- | API Key Hash | Requests | Cost (USD) |
-
-
-
-
+
+
+
+
+
+
+
+
+ Error Breakdown (24h)
+
+
+ Total Requests
+ --
+
+
+ Errors
+ --
+
+
+ Rate Limited (429)
+ --
+
+
+ Server Errors (5xx)
+ --
+
+
+ Avg Latency
+ --
+
+
+ Avg Cost / Request
+ --
+
+
+
+
+
+
+
+
+ API Keys (This Month)
+
+
+
+
+
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..83b924c 100644
--- a/internal/ledger/ledger.go
+++ b/internal/ledger/ledger.go
@@ -22,9 +22,15 @@ type Ledger interface {
GetTotalSpendByTenant(ctx context.Context, tenantID string, since, until time.Time) (float64, error)
// QueryCostTimeseries returns cost and request counts bucketed by time interval.
- // interval should be "hour" or "day". tenantID is optional (empty = all tenants).
+ // interval should be "minute", "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..5590611 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)
@@ -129,11 +129,14 @@ func (p *Postgres) QueryCosts(ctx context.Context, filter CostFilter) ([]CostEnt
func (p *Postgres) QueryCostTimeseries(ctx context.Context, interval string, since, until time.Time, tenantID string) ([]TimeseriesPoint, error) {
bucket := "date_trunc('hour', timestamp)"
- if interval == "day" {
+ switch interval {
+ case "minute":
+ bucket = "date_trunc('minute', timestamp)"
+ case "day":
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 +191,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..cec9412 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)
}
@@ -133,14 +133,17 @@ func (s *SQLite) QueryCostTimeseries(ctx context.Context, interval string, since
// Go's time.Time stores as "2006-01-02 15:04:05.999999 +0000 UTC" in SQLite,
// but strftime only parses ISO8601. Use substr to extract the datetime portion.
bucket := "strftime('%Y-%m-%d %H:00:00', substr(timestamp, 1, 19))"
- if interval == "day" {
+ switch interval {
+ case "minute":
+ bucket = "strftime('%Y-%m-%d %H:%M:00', substr(timestamp, 1, 19))"
+ case "day":
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 +197,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/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..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) {
@@ -68,7 +76,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 +274,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 +323,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 +368,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 +431,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 +476,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 +530,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))