eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// usage.go — the instance half of the usage pipe (#857,
2// docs/ai-provisioning.md phase 1). The sidecar counts model spend (it's the
3// only place spend is visible) and reports here over its bot token; this
4// handler validates and relays to the cloud ledger, authenticating with the
5// per-space meter token the provisioner pushed into the env
6// (LCHAT_METER_URL / LCHAT_METER_TOKEN). The instance persists nothing — it
7// stays a stateless relay, and rows are quantities only (token counts,
8// request counts, run counts), never content, per the
9// meter-only-what-ciphertext-exposes rule (#668).
10//
11// Unconfigured — a self-hosted instance, or a BYO-key space — the handler
12// accepts and drops: the sidecar keeps working and nothing is recorded
13// anywhere. The relayed response carries the space's AI budget state back to
14// the sidecar; today the cloud always answers "ok" (phase 3 computes it).
15package main
16
17import (
18 "bytes"
19 "encoding/json"
20 "fmt"
21 "log"
22 "net/http"
23 "net/url"
24 "path"
25 "regexp"
26 "time"
27)
28
29// botUsageRow is the wire shape shared by both hops (sidecar → instance →
30// cloud) and the cloud ledger's cell: a (day, model, dimension) quantity.
31type botUsageRow struct {
32 Day string `json:"day"`
33 Model string `json:"model,omitempty"`
34 Dimension string `json:"dimension"`
35 Quantity int64 `json:"quantity"`
36}
37
38var usageDayRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
39
40// usageDimensions is the closed set a report may carry — additions are a
41// deliberate schema decision, not a sidecar deploy detail.
42var usageDimensions = map[string]bool{
43 "llm_requests": true,
44 "llm_input_tokens": true,
45 "llm_output_tokens": true,
46 "llm_cache_write_tokens": true,
47 "llm_cache_read_tokens": true,
48 "charter_runs": true,
49}
50
51const maxUsageRows = 64
52
53// usageReportIDRe: the dedupe key a sidecar mints per flush — hex, bounded.
54// The ledger records it and drops replays, so an ack lost after a commit
55// can't double-count (the sidecar resends the same report verbatim).
56var usageReportIDRe = regexp.MustCompile(`^[0-9a-f]{16,64}$`)
57
58// validateUsageReport is shared by the instance relay and the cloud
59// ingester, so a report the relay accepts is by construction one the ledger
60// accepts.
61func validateUsageReport(reportID string, rows []botUsageRow) error {
62 if !usageReportIDRe.MatchString(reportID) {
63 return fmt.Errorf("bad report id")
64 }
65 if len(rows) == 0 || len(rows) > maxUsageRows {
66 return fmt.Errorf("1..%d rows required", maxUsageRows)
67 }
68 for _, row := range rows {
69 switch {
70 case !usageDayRe.MatchString(row.Day):
71 return fmt.Errorf("bad day %q", row.Day)
72 case !usageDimensions[row.Dimension]:
73 return fmt.Errorf("unknown dimension %q", row.Dimension)
74 case len(row.Model) > 128:
75 return fmt.Errorf("model too long")
76 case row.Quantity <= 0 || row.Quantity > 1e12:
77 return fmt.Errorf("bad quantity %d", row.Quantity)
78 }
79 }
80 return nil
81}
82
83// postBotUsage ingests a sidecar's usage report. The right principal is a
84// bot's own device token, same gate as the charter wake-time report.
85func (s *Server) postBotUsage(w http.ResponseWriter, r *http.Request) {
86 var body struct {
87 Token string `json:"token"`
88 ReportID string `json:"report_id"`
89 Rows []botUsageRow `json:"rows"`
90 }
91 if json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body) != nil {
92 httpError(w, http.StatusBadRequest, "bad request")
93 return
94 }
95 uid, ok := s.db.deviceTokenUser(body.Token)
96 if !ok || !s.db.isAgent(uid) {
97 httpError(w, http.StatusForbidden, "bot token required")
98 return
99 }
100 if err := validateUsageReport(body.ReportID, body.Rows); err != nil {
101 httpError(w, http.StatusBadRequest, err.Error())
102 return
103 }
104 if s.meterURL == "" || s.meterToken == "" {
105 // No meter configured: accept and drop, and say so — "unmetered" is
106 // what lets a BYO-key or self-hosted sidecar tell "nothing counts
107 // here" from "the meter is down".
108 writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": map[string]any{"state": "unmetered"}})
109 return
110 }
111 budget, err := relayUsage(s.meterURL, s.meterToken, body.ReportID, body.Rows)
112 if err != nil {
113 // The sidecar restores its counters and retries next tick — nothing
114 // is lost to a cloud blip, so a plain 502 is the whole story.
115 httpError(w, http.StatusBadGateway, "usage relay failed")
116 return
117 }
118 writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": budget})
119}
120
121// meterSibling swaps the last path segment of the meter URL for a sibling
122// endpoint — the meter env carries one URL (…/api/usage) and the opt-in
123// signal (…/api/ai-optin) rides the same credential and base.
124func meterSibling(meterURL, name string) string {
125 u, err := url.Parse(meterURL)
126 if err != nil {
127 return ""
128 }
129 u.Path = path.Join(path.Dir(u.Path), name)
130 return u.String()
131}
132
133// signalAIOptIn tells the provisioner this space wants its bot sidecar
134// (#858). Fire-and-forget from setSettings: the attach is idempotent, a
135// failure only means the admin flips the toggle again (or asks), and a
136// self-hosted instance (no meter env) has no provisioner to ask.
137func (s *Server) signalAIOptIn() {
138 if s.meterURL == "" || s.meterToken == "" {
139 return
140 }
141 target := meterSibling(s.meterURL, "ai-optin")
142 if target == "" {
143 return
144 }
145 req, err := http.NewRequest("POST", target, nil)
146 if err != nil {
147 return
148 }
149 req.Header.Set("Authorization", "Bearer "+s.meterToken)
150 client := &http.Client{Timeout: 30 * time.Second}
151 resp, err := client.Do(req)
152 if err != nil {
153 log.Printf("ai-optin: provisioner unreachable: %v", err)
154 return
155 }
156 defer resp.Body.Close()
157 if resp.StatusCode != http.StatusOK {
158 log.Printf("ai-optin: provisioner answered HTTP %d", resp.StatusCode)
159 return
160 }
161 log.Printf("ai-optin: sidecar requested from the provisioner")
162}
163
164// relayUsage forwards a validated report to the cloud ledger and returns the
165// budget object from its response.
166func relayUsage(meterURL, meterToken, reportID string, rows []botUsageRow) (map[string]any, error) {
167 raw, err := json.Marshal(map[string]any{"report_id": reportID, "rows": rows})
168 if err != nil {
169 return nil, err
170 }
171 req, err := http.NewRequest("POST", meterURL, bytes.NewReader(raw))
172 if err != nil {
173 return nil, err
174 }
175 req.Header.Set("Content-Type", "application/json")
176 req.Header.Set("Authorization", "Bearer "+meterToken)
177 client := &http.Client{Timeout: 15 * time.Second}
178 resp, err := client.Do(req)
179 if err != nil {
180 return nil, err
181 }
182 defer resp.Body.Close()
183 if resp.StatusCode != http.StatusOK {
184 return nil, fmt.Errorf("meter: HTTP %d", resp.StatusCode)
185 }
186 var out struct {
187 OK bool `json:"ok"`
188 Budget map[string]any `json:"budget"`
189 }
190 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
191 return nil, err
192 }
193 if out.Budget == nil {
194 out.Budget = map[string]any{"state": "ok"}
195 }
196 return out.Budget, nil
197}
198