eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// cloud_budget.go — phase 3 of docs/ai-provisioning.md: the allowance and
2// pool math that turns the usage ledger (#857) into a budget answer. Money
3// only ever appears here as CONFIG (rates, allowance, pool — env, set by the
4// operator) and as arithmetic over the ledger's quantities; no payment
5// machinery lives in this file. The forwarding of over-allowance usage to
6// Stripe Billing Meters is deliberately NOT built yet — it lands with #859's
7// AI add-on product, after the rates and allowance below are reviewed and
8// published in docs/tier-economics.md.
9//
10// The budget model, per the locked decisions (Paul 2026-07-14):
11// - Every space gets a small included monthly allowance (in EUR of model
12// cost, computed from per-model rates over the four token counters).
13// - A fleet-wide monthly pool bounds the aggregate subsidy: however many
14// free spaces opt in, the total included spend has a ceiling — the
15// creates_log circuit-breaker pattern, applied to tokens.
16// - Exhausted (space over allowance, or pool drained) means the sidecar
17// drops to the deterministic command floor with one honest notice
18// (internal/bot/meter.go); nothing breaks, nothing is deleted.
19// - Unconfigured = measure-only: every report answers "ok", exactly the
20// phase-1 behavior. BYO-key and self-hosted sidecars never report at all.
21package main
22
23import (
24 "encoding/json"
25 "fmt"
26 "log"
27 "time"
28)
29
30// aiRates prices one model's tokens, EUR per million, one rate per counter —
31// cache reads and writes price differently from fresh input, which is why the
32// ledger keeps all four dimensions distinct.
33type aiRates struct {
34 In float64 `json:"in"`
35 Out float64 `json:"out"`
36 CacheWrite float64 `json:"cw"`
37 CacheRead float64 `json:"cr"`
38}
39
40// parseAIRates reads LCHAT_CLOUD_AI_RATES — a JSON object keyed by model id,
41// e.g. {"claude-sonnet-5":{"in":3,"out":15,"cw":3.75,"cr":0.3}}. Rates are
42// EUR per million tokens. Empty = no pricing = the budget never bites.
43func parseAIRates(v string) (map[string]aiRates, error) {
44 if v == "" {
45 return nil, nil
46 }
47 rates := map[string]aiRates{}
48 if err := json.Unmarshal([]byte(v), &rates); err != nil {
49 return nil, fmt.Errorf("bad LCHAT_CLOUD_AI_RATES: %v", err)
50 }
51 for model, r := range rates {
52 if r.In < 0 || r.Out < 0 || r.CacheWrite < 0 || r.CacheRead < 0 {
53 return nil, fmt.Errorf("negative rate for %q", model)
54 }
55 }
56 return rates, nil
57}
58
59// budgetMonth is the allowance window — a UTC calendar month, matching the
60// ledger's day grain.
61func budgetMonth() string { return time.Now().UTC().Format("2006-01") }
62
63// usageCostEUR prices ledger rows against the rate book. Unknown models and
64// non-token dimensions cost zero — activity counters (charter_runs) are
65// measurement, not money, and a model missing from the rate book is an
66// operator gap worth logging, not a reason to guess a price.
67func usageCostEUR(rows []botUsageRow, rates map[string]aiRates) float64 {
68 var eur float64
69 for _, row := range rows {
70 r, ok := rates[row.Model]
71 if !ok {
72 if row.Model != "" {
73 log.Printf("cloud: budget: no rate for model %q — its tokens price as 0 (add it to LCHAT_CLOUD_AI_RATES)", row.Model)
74 }
75 continue
76 }
77 perM := map[string]float64{
78 "llm_input_tokens": r.In,
79 "llm_output_tokens": r.Out,
80 "llm_cache_write_tokens": r.CacheWrite,
81 "llm_cache_read_tokens": r.CacheRead,
82 }[row.Dimension]
83 eur += float64(row.Quantity) / 1e6 * perM
84 }
85 return eur
86}
87
88// budgetState answers one space's state for a flush response: "ok" or
89// "exhausted". Ordered cheap-to-dear: no config short-circuits before any
90// ledger read, the per-space allowance before the fleet-wide sum.
91func (s *cloudServer) budgetState(spaceID int64) string {
92 if len(s.aiRates) == 0 || s.aiAllowanceEUR <= 0 {
93 return "ok" // measure-only until the operator prices the budget
94 }
95 month := budgetMonth()
96 rows, err := s.db.usageForMonth(spaceID, month)
97 if err != nil {
98 log.Printf("cloud: budget: space %d month read: %v", spaceID, err)
99 return "ok" // a ledger blip must never silence a space's bot
100 }
101 if usageCostEUR(rows, s.aiRates) >= s.aiAllowanceEUR {
102 return "exhausted"
103 }
104 if s.aiPoolEUR > 0 {
105 fleet, err := s.db.fleetUsageForMonth(month)
106 if err != nil {
107 log.Printf("cloud: budget: fleet month read: %v", err)
108 return "ok"
109 }
110 if usageCostEUR(fleet, s.aiRates) >= s.aiPoolEUR {
111 log.Printf("cloud: budget: the fleet AI pool for %s is spent — subsidized AI paused fleet-wide", month)
112 return "exhausted"
113 }
114 }
115 return "ok"
116}
117