// cloud_budget.go — phase 3 of docs/ai-provisioning.md: the allowance and // pool math that turns the usage ledger (#857) into a budget answer. Money // only ever appears here as CONFIG (rates, allowance, pool — env, set by the // operator) and as arithmetic over the ledger's quantities; no payment // machinery lives in this file. The forwarding of over-allowance usage to // Stripe Billing Meters is deliberately NOT built yet — it lands with #859's // AI add-on product, after the rates and allowance below are reviewed and // published in docs/tier-economics.md. // // The budget model, per the locked decisions (Paul 2026-07-14): // - Every space gets a small included monthly allowance (in EUR of model // cost, computed from per-model rates over the four token counters). // - A fleet-wide monthly pool bounds the aggregate subsidy: however many // free spaces opt in, the total included spend has a ceiling — the // creates_log circuit-breaker pattern, applied to tokens. // - Exhausted (space over allowance, or pool drained) means the sidecar // drops to the deterministic command floor with one honest notice // (internal/bot/meter.go); nothing breaks, nothing is deleted. // - Unconfigured = measure-only: every report answers "ok", exactly the // phase-1 behavior. BYO-key and self-hosted sidecars never report at all. package main import ( "encoding/json" "fmt" "log" "time" ) // aiRates prices one model's tokens, EUR per million, one rate per counter — // cache reads and writes price differently from fresh input, which is why the // ledger keeps all four dimensions distinct. type aiRates struct { In float64 `json:"in"` Out float64 `json:"out"` CacheWrite float64 `json:"cw"` CacheRead float64 `json:"cr"` } // parseAIRates reads LCHAT_CLOUD_AI_RATES — a JSON object keyed by model id, // e.g. {"claude-sonnet-5":{"in":3,"out":15,"cw":3.75,"cr":0.3}}. Rates are // EUR per million tokens. Empty = no pricing = the budget never bites. func parseAIRates(v string) (map[string]aiRates, error) { if v == "" { return nil, nil } rates := map[string]aiRates{} if err := json.Unmarshal([]byte(v), &rates); err != nil { return nil, fmt.Errorf("bad LCHAT_CLOUD_AI_RATES: %v", err) } for model, r := range rates { if r.In < 0 || r.Out < 0 || r.CacheWrite < 0 || r.CacheRead < 0 { return nil, fmt.Errorf("negative rate for %q", model) } } return rates, nil } // budgetMonth is the allowance window — a UTC calendar month, matching the // ledger's day grain. func budgetMonth() string { return time.Now().UTC().Format("2006-01") } // usageCostEUR prices ledger rows against the rate book. Unknown models and // non-token dimensions cost zero — activity counters (charter_runs) are // measurement, not money, and a model missing from the rate book is an // operator gap worth logging, not a reason to guess a price. func usageCostEUR(rows []botUsageRow, rates map[string]aiRates) float64 { var eur float64 for _, row := range rows { r, ok := rates[row.Model] if !ok { if row.Model != "" { log.Printf("cloud: budget: no rate for model %q — its tokens price as 0 (add it to LCHAT_CLOUD_AI_RATES)", row.Model) } continue } perM := map[string]float64{ "llm_input_tokens": r.In, "llm_output_tokens": r.Out, "llm_cache_write_tokens": r.CacheWrite, "llm_cache_read_tokens": r.CacheRead, }[row.Dimension] eur += float64(row.Quantity) / 1e6 * perM } return eur } // budgetState answers one space's state for a flush response: "ok" or // "exhausted". Ordered cheap-to-dear: no config short-circuits before any // ledger read, the per-space allowance before the fleet-wide sum. func (s *cloudServer) budgetState(spaceID int64) string { if len(s.aiRates) == 0 || s.aiAllowanceEUR <= 0 { return "ok" // measure-only until the operator prices the budget } month := budgetMonth() rows, err := s.db.usageForMonth(spaceID, month) if err != nil { log.Printf("cloud: budget: space %d month read: %v", spaceID, err) return "ok" // a ledger blip must never silence a space's bot } if usageCostEUR(rows, s.aiRates) >= s.aiAllowanceEUR { return "exhausted" } if s.aiPoolEUR > 0 { fleet, err := s.db.fleetUsageForMonth(month) if err != nil { log.Printf("cloud: budget: fleet month read: %v", err) return "ok" } if usageCostEUR(fleet, s.aiRates) >= s.aiPoolEUR { log.Printf("cloud: budget: the fleet AI pool for %s is spent — subsidized AI paused fleet-wide", month) return "exhausted" } } return "ok" }