eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The usage meter (#857, docs/ai-provisioning.md phase 1). The sidecar is the
2// only place model spend is visible, so it counts here and reports upward:
3// token counters per (day, model) plus activity counters, flushed to the
4// *instance* (POST /api/bot-usage, bot-token-gated like charter-next-due)
5// rather than straight to the cloud. Via-instance keeps the sidecar's egress
6// surface at its two destinations (the instance + the model API — docs/
7// bots.md) and keeps the meter token in exactly one env file, the instance's;
8// the instance relays to the cloud ledger and relays the budget state back.
9//
10// Counters are quantities only — token counts, request counts, run counts —
11// never content, per the meter-only-what-ciphertext-exposes rule (#668). They
12// live in memory: a sidecar restart loses at most one flush interval, which
13// is acceptable for metering (billing-grade reconciliation is cloud-side).
14package bot
15
16import (
17 "context"
18 "crypto/rand"
19 "encoding/hex"
20 "sync"
21 "time"
22)
23
24// UsageRow is one meter cell: a (day, model, dimension) quantity. Model is
25// empty for activity dimensions (charter_runs). Day is UTC — the ledger's
26// grain, deliberately coarse.
27type UsageRow struct {
28 Day string `json:"day"`
29 Model string `json:"model,omitempty"`
30 Dimension string `json:"dimension"`
31 Quantity int64 `json:"quantity"`
32}
33
34type meterKey struct{ day, model, dim string }
35
36type usageMeter struct {
37 mu sync.Mutex
38 rows map[meterKey]int64
39}
40
41func newUsageMeter() *usageMeter { return &usageMeter{rows: map[meterKey]int64{}} }
42
43func meterDay() string { return time.Now().UTC().Format("2006-01-02") }
44
45// addLLM records one Messages call's usage block, all four token counters
46// kept distinct (cache reads/writes price differently from fresh input), plus
47// the request count.
48func (m *usageMeter) addLLM(model string, u anthUsage) {
49 m.mu.Lock()
50 defer m.mu.Unlock()
51 day := meterDay()
52 for dim, n := range map[string]int64{
53 "llm_requests": 1,
54 "llm_input_tokens": u.InputTokens,
55 "llm_output_tokens": u.OutputTokens,
56 "llm_cache_write_tokens": u.CacheCreationInputTokens,
57 "llm_cache_read_tokens": u.CacheReadInputTokens,
58 } {
59 if n > 0 {
60 m.rows[meterKey{day, model, dim}] += n
61 }
62 }
63}
64
65// inc records a model-less activity dimension (e.g. charter_runs).
66func (m *usageMeter) inc(dim string, n int64) {
67 if n <= 0 {
68 return
69 }
70 m.mu.Lock()
71 defer m.mu.Unlock()
72 m.rows[meterKey{meterDay(), "", dim}] += n
73}
74
75// drain snapshots and clears the counters; the caller restores the snapshot
76// if the flush fails, so a transient error loses nothing.
77func (m *usageMeter) drain() []UsageRow {
78 m.mu.Lock()
79 defer m.mu.Unlock()
80 if len(m.rows) == 0 {
81 return nil
82 }
83 out := make([]UsageRow, 0, len(m.rows))
84 for k, q := range m.rows {
85 out = append(out, UsageRow{Day: k.day, Model: k.model, Dimension: k.dim, Quantity: q})
86 }
87 m.rows = map[meterKey]int64{}
88 return out
89}
90
91// restore folds a failed flush's rows back into the live counters.
92func (m *usageMeter) restore(rows []UsageRow) {
93 m.mu.Lock()
94 defer m.mu.Unlock()
95 for _, r := range rows {
96 m.rows[meterKey{r.Day, r.Model, r.Dimension}] += r.Quantity
97 }
98}
99
100// recordUsage is the model client's accounting tap (its record hook).
101func (r *Runtime) recordUsage(model string, u anthUsage) {
102 if r.meter != nil {
103 r.meter.addLLM(model, u)
104 }
105}
106
107// meterInc bumps an activity dimension from anywhere in the runtime.
108func (r *Runtime) meterInc(dim string, n int64) {
109 if r.meter != nil {
110 r.meter.inc(dim, n)
111 }
112}
113
114// meterFlushEvery paces reporting. Coarse on purpose: the ledger's grain is a
115// day, so nothing downstream needs freshness — this only bounds how much a
116// crash can lose.
117const meterFlushEvery = 5 * time.Minute
118
119// usageReport is one flush in flight: drained rows plus the id the ledger
120// dedupes replays on. A failed flush keeps the report VERBATIM and resends
121// it — never re-drains into it — because "failed" includes "the cloud
122// committed but the ack got lost", and only a byte-identical replay under
123// the same id lets the ledger recognize and drop the duplicate.
124type usageReport struct {
125 ID string `json:"report_id"`
126 Rows []UsageRow `json:"rows"`
127}
128
129func newReportID() string {
130 raw := make([]byte, 16)
131 if _, err := rand.Read(raw); err != nil {
132 // Out of entropy is unrecoverable weirdness; a constant would break
133 // dedupe silently, so fail the report instead (retried next tick).
134 return ""
135 }
136 return hex.EncodeToString(raw)
137}
138
139func (r *Runtime) meterLoop(ctx context.Context) {
140 t := time.NewTicker(meterFlushEvery)
141 defer t.Stop()
142 for {
143 select {
144 case <-t.C:
145 r.flushUsage()
146 case <-ctx.Done():
147 // Best-effort final flush — the graceful paths (sleep co-spawn
148 // SIGTERM, deploy restart) shouldn't drop their last interval.
149 r.flushUsage()
150 return
151 }
152 }
153}
154
155// flushUsage reports counters to the instance and remembers the budget state
156// the response carries (inert today; phase 3 gates the model path on it).
157// Runs only on the meterLoop goroutine, so pendingUsage needs no lock: a
158// pending report is retried as-is before anything new is drained, and fresh
159// counts keep accumulating in the meter for the report after it.
160func (r *Runtime) flushUsage() {
161 if r.meter == nil {
162 return
163 }
164 if r.pendingUsage == nil {
165 rows := r.meter.drain()
166 if len(rows) == 0 {
167 return
168 }
169 id := newReportID()
170 if id == "" {
171 r.meter.restore(rows)
172 return
173 }
174 r.pendingUsage = &usageReport{ID: id, Rows: rows}
175 }
176 var resp struct {
177 OK bool `json:"ok"`
178 Budget struct {
179 State string `json:"state"`
180 } `json:"budget"`
181 }
182 if err := r.post("/api/bot-usage", map[string]any{
183 "token": r.Session.Token, "report_id": r.pendingUsage.ID, "rows": r.pendingUsage.Rows,
184 }, &resp); err != nil {
185 r.logf("usage: flush failed (%v) — report retried next tick", err)
186 return
187 }
188 r.pendingUsage = nil
189 if resp.Budget.State != "" {
190 r.budgetState.Store(resp.Budget.State)
191 if resp.Budget.State != "exhausted" {
192 // Back within budget (a new month, a raised allowance): re-arm
193 // the one-time notice for the next exhaustion.
194 r.budgetNotified.Store(false)
195 }
196 }
197}
198
199// budgetExhausted is phase 3's enforcement gate (docs/ai-provisioning.md):
200// when the cloud's last flush response said this space's AI budget is spent,
201// every model path stands down and the deterministic command floor keeps
202// answering — the same degrade shape as ai_enabled off, plus one honest
203// notice. Unmetered, "ok", or never-flushed all read as within budget.
204func (r *Runtime) budgetExhausted() bool {
205 v, _ := r.budgetState.Load().(string)
206 return v == "exhausted"
207}
208
209// noteBudgetExhausted returns true exactly once per exhaustion (re-armed when
210// the budget recovers), so the bot explains itself without repeating. Process
211// memory only: a restart repeats the notice at most once more.
212func (r *Runtime) noteBudgetExhausted() bool {
213 return r.budgetNotified.CompareAndSwap(false, true)
214}
215