Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Verifieddanny/BurnGuard/llms.txt

Use this file to discover all available pages before exploring further.

BurnGuard is built on a proxy-first philosophy: the only reliable place to enforce a budget is on the wire, before a request reaches the provider. API-level monitoring — billing dashboards, cost anomaly detectors, usage alerts from the provider itself — all operate on data that is hours old by the time you see it. A local reverse proxy sees every byte of every request and response in real time, in process, with no network round-trip to a monitoring service. That is why BurnGuard is a binary on your machine rather than a SaaS product you integrate with.

Request Flow

Every AI call from your application passes through the following pipeline:

Reverse Proxy Routing

BurnGuard uses Go’s standard net/http/httputil.ReverseProxy with a custom Rewrite function. When a request arrives, the proxy splits the URL path on the first two segments to extract the provider name and the remaining path:
parts := strings.SplitN(pr.In.URL.Path, "/", 3)
// e.g. "/anthropic/v1/messages" → ["", "anthropic", "v1/messages"]

providerName := parts[1]          // "anthropic"
remainingPath := "/" + parts[2]   // "/v1/messages"

target, exists := ps.providers[providerName]
// target.Host = "api.anthropic.com"

pr.Out.URL.Scheme = target.Scheme  // "https"
pr.Out.URL.Host   = target.Host    // "api.anthropic.com"
pr.Out.URL.Path   = remainingPath  // "/v1/messages"
pr.Out.Host       = target.Host
The URL transformation looks like this:
Incoming (local)Forwarded (upstream)
http://localhost:8080/anthropic/v1/messageshttps://api.anthropic.com/v1/messages
http://localhost:8080/openai/v1/chat/completionshttps://api.openai.com/v1/chat/completions
All request headers — including your Authorization or x-api-key header — are forwarded to the upstream provider as-is. BurnGuard never reads, stores, or modifies your API key.

Token Counting and Cost Calculation

Non-Streaming Responses

For standard JSON responses the proxy reads the complete response body inside the ModifyResponse hook, parses the provider-specific usage fields, calculates cost, writes a record to SQLite, and then restores the body so it continues to your application unmodified:
body, err := io.ReadAll(resp.Body)
// ...
resp.Body = io.NopCloser(bytes.NewReader(body))

switch providerName {
case "anthropic":
    usage, inputCost, outputCost, model, err := anthropic.ExtractUsage(body)
    // ...
    ps.tracker.Add(inputCost + outputCost)
    ps.alerter.Check(ps.tracker.Total(), ps.budgetLimit)
case "openai":
    usage, inputCost, outputCost, model, err := openai.ExtractUsage(body)
    // ...
    ps.tracker.Add(inputCost + outputCost)
    ps.alerter.Check(ps.tracker.Total(), ps.budgetLimit)
}

Streaming Responses (SSE)

When the upstream response has Content-Type: text/event-stream, buffering the entire body would defeat the purpose of streaming. Instead, BurnGuard wraps the response body in a StreamReader that passes bytes through to the client unchanged while accumulating them in an internal buffer:
// stream.go — Read passes data through, buffer accumulates
func (sr *StreamReader) Read(p []byte) (int, error) {
    n, err := sr.reader.Read(p)
    if n > 0 {
        sr.buffer.Write(p[:n])
    }
    return n, err
}
When the stream closes (the client has received the full response), the Close method scans the buffer for SSE data: lines, strips the prefix, filters out [DONE] markers, and passes all data payloads to the provider-specific parser:
func (sr *StreamReader) Close() error {
    err := sr.reader.Close()

    var dataLines [][]byte
    scanner := bufio.NewScanner(&sr.buffer)
    for scanner.Scan() {
        line := scanner.Bytes()
        if bytes.HasPrefix(line, []byte("data: ")) {
            payload := bytes.TrimPrefix(line, []byte("data: "))
            if string(payload) == "[DONE]" {
                continue
            }
            dataLines = append(dataLines, payload)
        }
    }

    usage, parseErr := sr.parser(dataLines, sr.requestPath)
    // store, track, alert...
}
This design adds zero latency to the streaming path — the client receives each SSE event the instant the proxy reads it.

Pricing Models

Pricing tables are built into the binary and updated with each release. Both providers have non-uniform token pricing that BurnGuard handles correctly. Anthropic cache-aware pricing:
Token typeRate multiplier
Standard input1.00×
Cache creation input1.25× (25% premium)
Cache read input0.10× (90% discount)
OutputStandard output rate
OpenAI cached prompt pricing:
Token typeRate multiplier
Standard prompt1.00×
Cached prompt (older models)0.50× (50% discount)
Cached prompt (GPT-5.4+)0.10× (90% discount)
CompletionStandard completion rate

Budget Enforcement

The BudgetGuard middleware wraps the entire proxy and runs before every request is forwarded:
func BudgetGuard(next http.Handler, tracker *budget.Tracker) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if tracker.IsOverBudget() {
            http.Error(w, "Blocked!", http.StatusForbidden)
            return
        }
        next.ServeHTTP(w, r)
    })
}
The Tracker maintains total spend in memory, protected by a mutex so concurrent requests never race:
type Tracker struct {
    mutex       sync.Mutex
    totalSpend  float64
    budgetLimit float64
}

func (t *Tracker) IsOverBudget() bool {
    t.mutex.Lock()
    defer t.mutex.Unlock()
    return t.totalSpend >= t.budgetLimit
}

func (t *Tracker) Add(cost float64) {
    t.mutex.Lock()
    defer t.mutex.Unlock()
    t.totalSpend += cost
}
On startup, main.go seeds the tracker with the cumulative spend already stored in SQLite, so the budget limit is enforced correctly across proxy restarts:
var totalSpend float64
row := conn.QueryRow("SELECT COALESCE(SUM(cost), 0) FROM requests")
row.Scan(&totalSpend)

tracker := budget.NewTracker(totalSpend, cfg.Budget.Limit)

Alert Delivery

After every request, the proxy calls alerter.Check(spent, budget). The Alerter iterates through the configured thresholds and fires a notification the first time each one is crossed:
func (a *Alerter) Check(spent, budget float64) {
    // ...
    ratio := spent / budget
    for _, threshold := range a.thresholds {
        if ratio >= threshold && !a.triggered[threshold] {
            a.triggered[threshold] = true
            percent := int(threshold * 100)
            message := fmt.Sprintf(
                "BurnGuard: Budget %d%% used ($%.4f of $%.4f)",
                percent, spent, budget,
            )
            go a.send(message)
        }
    }
}
The triggered map ensures each threshold fires exactly once per proxy session. Notifications are sent concurrently in a goroutine so they never block the response path. If both Slack and Discord webhooks are configured they are posted simultaneously:
func (a *Alerter) send(message string) {
    if a.slackWebhook != "" {
        a.postJSON(a.slackWebhook, map[string]string{"text": message})
    }
    if a.discordWebhook != "" {
        a.postJSON(a.discordWebhook, map[string]string{"content": message})
    }
}

Cloud Sync

When sync.enabled is true, a background goroutine starts alongside the proxy and ticks on the configured interval (default 60 seconds):
func (s *Syncer) Start(ctx context.Context) {
    log.Printf("Sync started — every %v to %s", s.interval, s.url)
    ticker := time.NewTicker(s.interval)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            s.syncOnce(ctx)
        }
    }
}
On each tick, syncOnce fetches up to 100 unsynced records from SQLite, marshals them to JSON, and POSTs to POST /v1/usage on the BurnGuard API with a Bearer token:
req, _ := http.NewRequestWithContext(ctx, "POST", s.url+"/v1/usage", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.token)
On a 201 Created response the records are marked as synced in SQLite so they are never re-sent. Sync failures are logged but do not affect the proxy — requests continue to be forwarded and recorded locally regardless of cloud reachability.

Full Architecture

The proxy is completely stateless with respect to the upstream provider. It adds no extra network hop — requests are forwarded directly from your machine to api.anthropic.com or api.openai.com. The only overhead is the in-process token parsing that happens inside ModifyResponse or StreamReader.Close, both of which run after the provider has already sent its response.

Build docs developers (and LLMs) love