diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d35b16e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# CLAUDE.md + +Guidance for Claude Code and contributors working in this repository. + +## Project Overview + +AgentLedger is a Go reverse proxy that sits between AI agents and LLM providers. It +meters token usage per request, calculates costs, enforces daily/monthly budgets, and +exposes cost observability — all without requiring application code changes (agents +just point their SDK's base URL at the proxy). Module path: +`github.com/WDZ-Dev/agent-ledger`. + +## Architecture + +Request flow (all wired in `cmd/agentledger/serve.go` → `runServe`): + +1. `internal/proxy` (`Proxy.ServeHTTP`) is the core `httputil.ReverseProxy`. It reads the + request body, detects the provider, and runs pre-flight checks in order: blocklist → + agent loop detection → rate limit → tenant resolution → budget check → pre-flight cost + estimation (rejects if worst-case cost from `max_tokens` exceeds remaining budget). +2. Request metadata and start time are stashed in the request `context.Context` (typed + `ctxKey` constants), then the request is forwarded upstream via `rewrite` (rewrites URL + to the provider upstream, strips provider path prefix and agent headers). +3. `modifyResponse` parses the upstream response. Streaming (`text/event-stream`) responses + are wrapped by `internal/proxy/streaming.go` (`newStreamInterceptor`) to accumulate token + usage from SSE chunks; non-streaming responses are parsed directly. +4. Cost is computed by `internal/meter` (`Meter.Calculate`), a `ledger.UsageRecord` is built + and handed to the async `ledger.Recorder`, and the agent session cost is updated. + +Key packages under `internal/`: + +- `provider` — per-provider `Provider` interface (`Name/Match/ParseRequest/ParseResponse/ + ParseStreamChunk/UpstreamURL`). `Registry.Detect` picks a provider by matching the request. + OpenAI-compatible providers (Groq, Mistral, DeepSeek, xAI, Perplexity, Together, Fireworks, + OpenRouter, Cerebras, SambaNova) share `openai_compat.go` and route by path prefix (e.g. + `/groq/`). Also holds API-key extraction/hashing and agent-header helpers. +- `meter` — pricing table (`pricing.go`, `DefaultPricing`) + cost calc with exact-then- + longest-prefix model matching; `estimator.go` uses tiktoken to estimate tokens when the API + omits usage. +- `ledger` — storage. `Ledger` interface implemented by SQLite (`sqlite.go`, default) and + Postgres (`postgres.go`). Migrations are `//go:embed`ed from `migrations/{sqlite,postgres}` + and run via goose. `Recorder` is an async buffered write pipeline (drops on overflow). +- `budget` — `Manager.Check` returns Allow/Warn/Block; `circuit_breaker.go` provides + `BreakerTransport` (a `http.RoundTripper`) for upstream failure protection. +- `agent` — session tracking (`Tracker`), loop detection and "ghost agent" detection + (`detector.go`). Sessions persisted via the `SessionStore` interface (implemented by the + storage backends). +- `mcp` — Model Context Protocol metering: HTTP proxy (`httpproxy.go`) and a stdio wrapper + (`stdio.go`, driven by the `mcp-wrap` CLI command) that intercepts JSON-RPC `tools/call`. +- `tenant`, `ratelimit`, `alert` (Slack/webhook notifiers, rate-limited + multi), `admin` + (token-auth admin API + API-key blocklist), `otel` (OpenTelemetry → Prometheus metrics), + `dashboard` (embedded web UI + REST API). + +## Key Concepts + +- **UsageRecord** (`internal/ledger/models.go`) — one metered LLM call: provider, model, + hashed API key, token counts, cost, duration, status, and agent/session/user/tenant IDs. +- **API key hashing** — keys are never stored raw; `provider.HashAPIKey` fingerprints + prefix+suffix via SHA-256. +- **Agent headers** — `X-Agent-Id`, `X-Agent-Session`, `X-Agent-User`, `X-Agent-Task`, + `X-Agent-Session-End` (query-param fallbacks `?_agent_id=` etc.); stripped before upstream. +- **Estimated cost** — records are flagged `Estimated` when the model has no known pricing. + +## Commands + +Use the Makefile (Go is not always on PATH; targets assume tools under `~/go/bin`): + +- `make build` — compile to `bin/agentledger` (or `go build ./cmd/agentledger`). +- `make dev` — build + run `serve` with `configs/agentledger.example.yaml`. +- `make test` — `go test -race -cover -count=1 ./...`. +- `make test-short` — fast tests only (`-short`). +- `make test-coverage` — HTML coverage report. +- `make lint` / `make lint-fix` — golangci-lint (v2). +- `make fmt` — `gofmt` + `goimports -local github.com/WDZ-Dev/agent-ledger`. +- `make vet`, `make vulncheck` (govulncheck), `make tidy`. +- `make check` — `fmt vet lint test vulncheck` (mirrors CI in `.github/workflows/ci.yml`). +- `make setup` — install dev tools (golangci-lint, lefthook, govulncheck, goimports) + git hooks. + +CLI subcommands (`cmd/agentledger`): `serve`, `costs` (`--last`, `--by`, `--tenant`), +`export` (`--format csv|json`), `mcp-wrap -- `, `version`, `healthcheck` (hidden). + +## Conventions + +- Standard Go layout: entrypoint/CLI in `cmd/agentledger`, all logic in `internal/` + packages (not importable externally). One responsibility per package; `_test.go` beside + each source file. +- Errors wrapped with `fmt.Errorf("...: %w", err)`; error vars named `ErrFoo` (`errname`). +- Structured logging with `log/slog` throughout; a `*slog.Logger` is passed into constructors. +- Optional features are wired by passing `nil` (e.g. proxy accepts `nil` budget/tracker/ + metrics/limiter to disable them) and gated via `Enabled()` methods. +- IDs are ULIDs (`oklog/ulid/v2`). Linters enforced: govet, errcheck, staticcheck, gosec, + revive, goconst, bodyclose, and more (see `.golangci.yml`). Go 1.25. + +## Configuration + +Loaded via Viper in `internal/config/config.go` (`Load`). Sources, in order: config file → +environment variables → built-in defaults. + +- **Config file**: passed with `-c/--config`, else auto-discovered as `agentledger.yaml` in + `.`, `./configs`, `$HOME/.config/agentledger`, `/etc/agentledger`. See + `configs/agentledger.example.yaml`. +- **Environment variables**: prefix `AGENTLEDGER_`, dots → underscores (e.g. + `AGENTLEDGER_LISTEN`, `AGENTLEDGER_STORAGE_DSN`, `AGENTLEDGER_STORAGE_DRIVER`, + `AGENTLEDGER_ADMIN_TOKEN`). +- **Key settings**: `listen` (default `:8787`); `storage.driver` (`sqlite` default, or + `postgres`) + `storage.dsn`; `providers.*` (OpenAI/Anthropic enabled by default, extras + disabled); `budgets`, `rate_limits`, `agent` (loop/ghost thresholds), `tenants`, `alerts` + (Slack `webhook_url` / webhooks), `admin` (`enabled` + `token`), `mcp` (`enabled` + + `upstream`), `dashboard.enabled` (default true), `cors.allow_origins`, `tls.cert_file`/ + `key_file`, `circuit_breaker`, `recording.buffer_size`/`workers`, `log.level`/`format`. +- **`mcp-wrap`** reads agent context from `AGENTLEDGER_AGENT_ID`, `AGENTLEDGER_SESSION_ID`, + `AGENTLEDGER_USER_ID`, `AGENTLEDGER_TASK`. + +No required env vars for a default run (`agentledger serve` works with SQLite defaults).