| 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). |
| 15 | package main |
| 16 | |
| 17 | import ( |
| 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. |
| 31 | type 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 | |
| 38 | var 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. |
| 42 | var 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 | |
| 51 | const 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). |
| 56 | var 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. |
| 61 | func 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. |
| 85 | func (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. |
| 124 | func 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. |
| 137 | func (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. |
| 166 | func 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 | |