// usage.go — the instance half of the usage pipe (#857, // docs/ai-provisioning.md phase 1). The sidecar counts model spend (it's the // only place spend is visible) and reports here over its bot token; this // handler validates and relays to the cloud ledger, authenticating with the // per-space meter token the provisioner pushed into the env // (LCHAT_METER_URL / LCHAT_METER_TOKEN). The instance persists nothing — it // stays a stateless relay, and rows are quantities only (token counts, // request counts, run counts), never content, per the // meter-only-what-ciphertext-exposes rule (#668). // // Unconfigured — a self-hosted instance, or a BYO-key space — the handler // accepts and drops: the sidecar keeps working and nothing is recorded // anywhere. The relayed response carries the space's AI budget state back to // the sidecar; today the cloud always answers "ok" (phase 3 computes it). package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "net/url" "path" "regexp" "time" ) // botUsageRow is the wire shape shared by both hops (sidecar → instance → // cloud) and the cloud ledger's cell: a (day, model, dimension) quantity. type botUsageRow struct { Day string `json:"day"` Model string `json:"model,omitempty"` Dimension string `json:"dimension"` Quantity int64 `json:"quantity"` } var usageDayRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // usageDimensions is the closed set a report may carry — additions are a // deliberate schema decision, not a sidecar deploy detail. var usageDimensions = map[string]bool{ "llm_requests": true, "llm_input_tokens": true, "llm_output_tokens": true, "llm_cache_write_tokens": true, "llm_cache_read_tokens": true, "charter_runs": true, } const maxUsageRows = 64 // usageReportIDRe: the dedupe key a sidecar mints per flush — hex, bounded. // The ledger records it and drops replays, so an ack lost after a commit // can't double-count (the sidecar resends the same report verbatim). var usageReportIDRe = regexp.MustCompile(`^[0-9a-f]{16,64}$`) // validateUsageReport is shared by the instance relay and the cloud // ingester, so a report the relay accepts is by construction one the ledger // accepts. func validateUsageReport(reportID string, rows []botUsageRow) error { if !usageReportIDRe.MatchString(reportID) { return fmt.Errorf("bad report id") } if len(rows) == 0 || len(rows) > maxUsageRows { return fmt.Errorf("1..%d rows required", maxUsageRows) } for _, row := range rows { switch { case !usageDayRe.MatchString(row.Day): return fmt.Errorf("bad day %q", row.Day) case !usageDimensions[row.Dimension]: return fmt.Errorf("unknown dimension %q", row.Dimension) case len(row.Model) > 128: return fmt.Errorf("model too long") case row.Quantity <= 0 || row.Quantity > 1e12: return fmt.Errorf("bad quantity %d", row.Quantity) } } return nil } // postBotUsage ingests a sidecar's usage report. The right principal is a // bot's own device token, same gate as the charter wake-time report. func (s *Server) postBotUsage(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` ReportID string `json:"report_id"` Rows []botUsageRow `json:"rows"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } uid, ok := s.db.deviceTokenUser(body.Token) if !ok || !s.db.isAgent(uid) { httpError(w, http.StatusForbidden, "bot token required") return } if err := validateUsageReport(body.ReportID, body.Rows); err != nil { httpError(w, http.StatusBadRequest, err.Error()) return } if s.meterURL == "" || s.meterToken == "" { // No meter configured: accept and drop, and say so — "unmetered" is // what lets a BYO-key or self-hosted sidecar tell "nothing counts // here" from "the meter is down". writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": map[string]any{"state": "unmetered"}}) return } budget, err := relayUsage(s.meterURL, s.meterToken, body.ReportID, body.Rows) if err != nil { // The sidecar restores its counters and retries next tick — nothing // is lost to a cloud blip, so a plain 502 is the whole story. httpError(w, http.StatusBadGateway, "usage relay failed") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": budget}) } // meterSibling swaps the last path segment of the meter URL for a sibling // endpoint — the meter env carries one URL (…/api/usage) and the opt-in // signal (…/api/ai-optin) rides the same credential and base. func meterSibling(meterURL, name string) string { u, err := url.Parse(meterURL) if err != nil { return "" } u.Path = path.Join(path.Dir(u.Path), name) return u.String() } // signalAIOptIn tells the provisioner this space wants its bot sidecar // (#858). Fire-and-forget from setSettings: the attach is idempotent, a // failure only means the admin flips the toggle again (or asks), and a // self-hosted instance (no meter env) has no provisioner to ask. func (s *Server) signalAIOptIn() { if s.meterURL == "" || s.meterToken == "" { return } target := meterSibling(s.meterURL, "ai-optin") if target == "" { return } req, err := http.NewRequest("POST", target, nil) if err != nil { return } req.Header.Set("Authorization", "Bearer "+s.meterToken) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { log.Printf("ai-optin: provisioner unreachable: %v", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.Printf("ai-optin: provisioner answered HTTP %d", resp.StatusCode) return } log.Printf("ai-optin: sidecar requested from the provisioner") } // relayUsage forwards a validated report to the cloud ledger and returns the // budget object from its response. func relayUsage(meterURL, meterToken, reportID string, rows []botUsageRow) (map[string]any, error) { raw, err := json.Marshal(map[string]any{"report_id": reportID, "rows": rows}) if err != nil { return nil, err } req, err := http.NewRequest("POST", meterURL, bytes.NewReader(raw)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+meterToken) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("meter: HTTP %d", resp.StatusCode) } var out struct { OK bool `json:"ok"` Budget map[string]any `json:"budget"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } if out.Budget == nil { out.Budget = map[string]any{"state": "ok"} } return out.Budget, nil }