eleven / ocho Public

usage pipe: sidecar token accounting → instance relay → cloud ledger (#867)

* server+bot+cloud: the usage pipe — sidecar token accounting relayed into the cloud ledger (#857)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+bot+cloud: review fixes — serialize instance-env pushes, idempotent usage reports

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 13a6b9eb1ec63a1157cc6844cdec7b4b9a33f785 parent 8d35e25
20 files changed, +1298 −40
  • cloud.go +11 −0
  • cloud_db.go +123 −8
  • cloud_usage.go +97 −0
  • cloud_usage_test.go +144 −0
  • controld.go +52 −0
  • docs/cloud-provisioner.md +25 −0
  • internal/bot/anthropic.go +49 −2
  • internal/bot/brain.go +1 −1
  • internal/bot/chartersched.go +1 −0
  • internal/bot/connect_v2_test.go +1 −1
  • internal/bot/meter.go +192 −0
  • internal/bot/meter_test.go +190 −0
  • internal/bot/research_test.go +3 −3
  • internal/bot/runtime.go +23 −9
  • internal/bot/task_test.go +1 −1
  • main.go +9 −1
  • meter.go +61 −14
  • meter_test.go +38 −0
  • usage.go +151 −0
  • usage_test.go +126 −0
diff --git a/cloud.go b/cloud.go
index 047e712..8ca6b1f 100644
--- a/cloud.go
+++ b/cloud.go
@@ -87,6 +87,10 @@ type cloudServer struct {
// (cloudreplication.go); empty = classes never pushed, every space streams.
replicationClasses map[string]string
createBudget int // max creates fleet-wide per trailing 24h (0 = uncapped)
+ // usageURL is this service's own public /api/usage URL, pushed into each
+ // instance's env (LCHAT_METER_URL) alongside a minted meter token so
+ // sidecar usage reports can flow (#857). Empty = the usage pipe is off.
+ usageURL string
}
func (s *cloudServer) handler() http.Handler {
@@ -100,6 +104,9 @@ func (s *cloudServer) handler() http.Handler {
mux.HandleFunc("POST /api/instances/{host}/checkout", s.callerAuth(s.checkoutHandler))
mux.HandleFunc("POST /api/instances/{host}/portal", s.callerAuth(s.portalHandler))
mux.HandleFunc("POST /api/stripe/webhook", s.stripeWebhookHandler)
+ // Usage ingest (#857): meter-token-gated, not caller-gated — an instance
+ // can only ever report as itself.
+ mux.HandleFunc("POST /api/usage", s.usageHandler)
// Admin surface: mint / list / revoke caller tokens at runtime (mirrors the
// push relay's admin API).
mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler))
@@ -475,6 +482,10 @@ func runCloud(args []string) {
storageCaps: storageCaps,
replicationClasses: replicationClasses,
createBudget: *createBudget,
+ usageURL: strings.TrimSpace(os.Getenv("LCHAT_CLOUD_USAGE_URL")),
+ }
+ if s.usageURL != "" {
+ log.Printf("cloud: usage pipe on — meter tokens push %s", s.usageURL)
}
if *createBudget > 0 {
log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget)
diff --git a/cloud_db.go b/cloud_db.go
index c8b04a4..bed2a39 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -73,7 +73,121 @@ CREATE TABLE IF NOT EXISTS creates_log (
if err := db.migrateBilling(); err != nil {
return err
}
- return db.migrateMeter()
+ if err := db.migrateMeter(); err != nil {
+ return err
+ }
+ return db.migrateUsage()
+}
+
+// migrateUsage (#857): the per-space usage ledger — quantities only (token
+// counts, request counts, run counts), never content — plus the meter token
+// each instance authenticates its reports with. The token itself is pushed
+// into the instance env (LCHAT_METER_URL/_TOKEN) by the sweep; only its hash
+// lives here, same posture as caller tokens.
+func (db *cloudDB) migrateUsage() error {
+ if has, err := db.spacesHasColumn("meter_token_hash"); err != nil {
+ return err
+ } else if !has {
+ if _, err := db.sql.Exec(`ALTER TABLE spaces ADD COLUMN meter_token_hash TEXT NOT NULL DEFAULT ''`); err != nil {
+ return err
+ }
+ }
+ _, err := db.sql.Exec(`CREATE TABLE IF NOT EXISTS usage (
+ space_id INTEGER NOT NULL,
+ day TEXT NOT NULL,
+ model TEXT NOT NULL DEFAULT '',
+ dimension TEXT NOT NULL,
+ quantity INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (space_id, day, model, dimension)
+);
+-- Applied report ids — the dedupe ledger that makes ingest idempotent. A
+-- sidecar resends a report verbatim when an ack is lost; the id tells a
+-- replay from new usage. Pruned after a retention window (the sidecar
+-- retries on a minutes scale, so days of memory is generous).
+CREATE TABLE IF NOT EXISTS usage_reports (
+ space_id INTEGER NOT NULL,
+ report_id TEXT NOT NULL,
+ applied_at INTEGER NOT NULL,
+ PRIMARY KEY (space_id, report_id)
+)`)
+ return err
+}
+
+// setMeterTokenHash records the hash of the meter token last pushed to a
+// space's instance. Overwriting is the rotation path: last push wins.
+func (db *cloudDB) setMeterTokenHash(id int64, hash string) error {
+ _, err := db.sql.Exec(`UPDATE spaces SET meter_token_hash = ? WHERE id = ?`, hash, id)
+ return err
+}
+
+// spaceByMeterToken resolves a usage report's bearer token to its space. An
+// empty stored hash never matches (hashToken of any presented token is
+// non-empty), so unmetered spaces can't be reported against.
+func (db *cloudDB) spaceByMeterToken(token string) (int64, bool) {
+ var id int64
+ err := db.sql.QueryRow(
+ `SELECT id FROM spaces WHERE meter_token_hash = ? AND meter_token_hash != ''`,
+ hashToken(token)).Scan(&id)
+ return id, err == nil
+}
+
+// addUsage folds one report into the ledger — additive upserts, so repeated
+// partial-day reports accumulate instead of clobbering. Idempotent on
+// (space, report id): the report row and the quantity upserts share one
+// transaction, so a replay of an already-applied report (ack lost after
+// commit) is recognized and dropped whole.
+func (db *cloudDB) addUsage(spaceID int64, reportID string, rows []botUsageRow) error {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ res, err := tx.Exec(`INSERT INTO usage_reports(space_id, report_id, applied_at)
+VALUES(?, ?, ?) ON CONFLICT(space_id, report_id) DO NOTHING`,
+ spaceID, reportID, time.Now().Unix())
+ if err != nil {
+ return err
+ }
+ if n, err := res.RowsAffected(); err != nil {
+ return err
+ } else if n == 0 {
+ return nil // replay of an applied report — already counted
+ }
+ for _, r := range rows {
+ if _, err := tx.Exec(`INSERT INTO usage(space_id, day, model, dimension, quantity)
+VALUES(?, ?, ?, ?, ?)
+ON CONFLICT(space_id, day, model, dimension) DO UPDATE SET quantity = quantity + excluded.quantity`,
+ spaceID, r.Day, r.Model, r.Dimension, r.Quantity); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// pruneUsageReports drops applied-report ids older than the cutoff. The
+// sidecar's retry horizon is minutes, so days of dedupe memory is generous.
+func (db *cloudDB) pruneUsageReports(cutoff int64) error {
+ _, err := db.sql.Exec(`DELETE FROM usage_reports WHERE applied_at < ?`, cutoff)
+ return err
+}
+
+// usageFor reads a space's ledger rows (tests + the future usage surface).
+func (db *cloudDB) usageFor(spaceID int64) ([]botUsageRow, error) {
+ rows, err := db.sql.Query(
+ `SELECT day, model, dimension, quantity FROM usage WHERE space_id = ? ORDER BY day, model, dimension`, spaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []botUsageRow
+ for rows.Next() {
+ var r botUsageRow
+ if err := rows.Scan(&r.Day, &r.Model, &r.Dimension, &r.Quantity); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
}
// migrateSpacesID rebuilds a spaces table created under the original host-PK
@@ -522,17 +636,18 @@ func (db *cloudDB) setEntitlementTier(id int64, ent, tier string, paidUntil int6
// meterRow is one space in the meter's sweep set — enough billing state to
// reconcile its quota alongside the byte reading.
type meterRow struct {
- ID int64
- Host string
- Entitlement string
- Tier string
- QuotaBytes int64
+ ID int64
+ Host string
+ Entitlement string
+ Tier string
+ QuotaBytes int64
+ MeterTokenHash string
}
// allFinalizedSpaces is the meter's sweep set: every space with a hostname.
func (db *cloudDB) allFinalizedSpaces() ([]meterRow, error) {
rows, err := db.sql.Query(
- `SELECT id, host, entitlement, tier, quota_bytes FROM spaces WHERE host IS NOT NULL ORDER BY id`)
+ `SELECT id, host, entitlement, tier, quota_bytes, meter_token_hash FROM spaces WHERE host IS NOT NULL ORDER BY id`)
if err != nil {
return nil, err
}
@@ -540,7 +655,7 @@ func (db *cloudDB) allFinalizedSpaces() ([]meterRow, error) {
var out []meterRow
for rows.Next() {
var m meterRow
- if err := rows.Scan(&m.ID, &m.Host, &m.Entitlement, &m.Tier, &m.QuotaBytes); err != nil {
+ if err := rows.Scan(&m.ID, &m.Host, &m.Entitlement, &m.Tier, &m.QuotaBytes, &m.MeterTokenHash); err != nil {
return nil, err
}
out = append(out, m)
diff --git a/cloud_usage.go b/cloud_usage.go
new file mode 100644
index 0000000..07d1d3d
--- /dev/null
+++ b/cloud_usage.go
@@ -0,0 +1,97 @@
+// cloud_usage.go — the cloud half of the usage pipe (#857,
+// docs/ai-provisioning.md phase 1): ingest per-space usage reports into the
+// ledger, and mint + push the meter tokens that authenticate them.
+//
+// The pipe measures before any money exists: rows are quantities only (token
+// counts, request counts, run counts) keyed by (space, day, model, dimension)
+// — never content, never per-member attribution (#668's rule). Phase 3 turns
+// the ledger into an allowance/budget answer; until then every report is
+// answered "ok".
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+)
+
+// usageHandler ingests one instance's report. Auth is the space's meter
+// token — minted by the sweep below, pushed into the instance env, and never
+// shown anywhere else — so an instance can only ever report as itself.
+func (s *cloudServer) usageHandler(w http.ResponseWriter, r *http.Request) {
+ spaceID, ok := s.db.spaceByMeterToken(bearerTokenHeader(r))
+ if !ok {
+ writeJSON(w, http.StatusForbidden, map[string]any{"error": "bad meter token"})
+ return
+ }
+ var body struct {
+ ReportID string `json:"report_id"`
+ Rows []botUsageRow `json:"rows"`
+ }
+ if json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body) != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]any{"error": "bad request"})
+ return
+ }
+ // Same validator as the instance relay, so a report the relay accepted is
+ // by construction one the ledger accepts.
+ if err := validateUsageReport(body.ReportID, body.Rows); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
+ return
+ }
+ // addUsage is idempotent on (space, report id): a replay whose first ack
+ // was lost lands here again and is dropped, never double-counted.
+ if err := s.db.addUsage(spaceID, body.ReportID, body.Rows); err != nil {
+ log.Printf("cloud: usage ingest space %d: %v", spaceID, err)
+ writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "ledger error"})
+ return
+ }
+ // Budget is phase 3 (docs/ai-provisioning.md): allowance and pool math
+ // land here; until then the meter measures and everything is within
+ // budget by definition.
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": map[string]any{"state": "ok"}})
+}
+
+// ---- meter-token mint + push (sweep-driven) --------------------------------------
+
+// pushMeterToken mints a fresh meter token for a space and hands it to the
+// instance via controld (LCHAT_METER_URL/_TOKEN in the env). Push first,
+// record the hash after: if recording fails the next sweep mints and pushes
+// again, and the last push wins — the same self-healing shape as pushQuota.
+func (s *cloudServer) pushMeterToken(id int64, host string) {
+ token, err := randToken()
+ if err != nil {
+ log.Printf("cloud: meter token %s: %v", host, err)
+ return
+ }
+ if err := s.controlSetMeter(host, s.usageURL, token); err != nil {
+ log.Printf("cloud: meter token push %s: %v (sweep will retry)", host, err)
+ return
+ }
+ if err := s.db.setMeterTokenHash(id, hashToken(token)); err != nil {
+ log.Printf("cloud: meter token record %s: %v (sweep re-mints)", host, err)
+ return
+ }
+ log.Printf("cloud: meter token → %s", host)
+}
+
+// controlSetMeter pushes one instance's meter endpoint + token to controld.
+func (s *cloudServer) controlSetMeter(host, url, token string) error {
+ payload, _ := json.Marshal(map[string]any{"url": url, "token": token})
+ req, err := http.NewRequest("POST", "http://control/instances/"+host+"/meter", bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+s.controlToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := s.control.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("control meter %s: HTTP %d", host, resp.StatusCode)
+ }
+ return nil
+}
diff --git a/cloud_usage_test.go b/cloud_usage_test.go
new file mode 100644
index 0000000..e879583
--- /dev/null
+++ b/cloud_usage_test.go
@@ -0,0 +1,144 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func ingestUsage(t *testing.T, s *cloudServer, token string, rows []botUsageRow) (int, map[string]any) {
+ t.Helper()
+ return ingestUsageReport(t, s, token, "bbbbbbbbbbbbbbbb", rows)
+}
+
+func ingestUsageReport(t *testing.T, s *cloudServer, token, reportID string, rows []botUsageRow) (int, map[string]any) {
+ t.Helper()
+ raw, _ := json.Marshal(map[string]any{"report_id": reportID, "rows": rows})
+ req := httptest.NewRequest("POST", "/api/usage", bytes.NewReader(raw))
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ s.usageHandler(rec, req)
+ var body map[string]any
+ json.NewDecoder(rec.Body).Decode(&body)
+ return rec.Code, body
+}
+
+// Ingest: the meter token resolves to exactly one space, rows upsert
+// additively (partial-day reports accumulate), and a stranger is refused.
+func TestUsageIngest(t *testing.T) {
+ db := openTestCloudDB(t)
+ id := insertSpaceRow(t, db, "space.11ch.at", 1000)
+ if err := db.setMeterTokenHash(id, hashToken("meter-tok")); err != nil {
+ t.Fatal(err)
+ }
+ s := &cloudServer{db: db}
+
+ rows := []botUsageRow{
+ {Day: "2026-07-14", Model: "claude-sonnet-5", Dimension: "llm_input_tokens", Quantity: 100},
+ {Day: "2026-07-14", Dimension: "charter_runs", Quantity: 2},
+ }
+ if code, body := ingestUsageReport(t, s, "meter-tok", "aa11aa11aa11aa11", rows); code != http.StatusOK {
+ t.Fatalf("ingest: %d %v", code, body)
+ }
+ // A second partial-day report (new id) adds, never clobbers.
+ if code, _ := ingestUsageReport(t, s, "meter-tok", "aa22aa22aa22aa22", rows[:1]); code != http.StatusOK {
+ t.Fatal("second ingest failed")
+ }
+ // A REPLAY (same id — the ack-lost retry) is recognized and dropped whole.
+ if code, _ := ingestUsageReport(t, s, "meter-tok", "aa11aa11aa11aa11", rows); code != http.StatusOK {
+ t.Fatal("replay must still answer ok")
+ }
+ got, err := db.usageFor(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ byKey := map[string]int64{}
+ for _, r := range got {
+ byKey[r.Model+"|"+r.Dimension] = r.Quantity
+ }
+ if byKey["claude-sonnet-5|llm_input_tokens"] != 200 || byKey["|charter_runs"] != 2 {
+ t.Fatalf("ledger = %v (replay must not double-count)", byKey)
+ }
+ // Prune drops old dedupe ids; a post-prune replay would re-count, which
+ // is why the window is days against a minutes-scale retry horizon.
+ if err := db.pruneUsageReports(time.Now().Unix() + 1); err != nil {
+ t.Fatal(err)
+ }
+
+ if code, _ := ingestUsage(t, s, "wrong", rows); code != http.StatusForbidden {
+ t.Fatal("bad token must be refused")
+ }
+ if code, _ := ingestUsage(t, s, "", rows); code != http.StatusForbidden {
+ t.Fatal("missing token must be refused")
+ }
+ // An unmetered space's empty hash must never match any presented token.
+ other := insertSpaceRow(t, db, "other.11ch.at", 1000)
+ if sid, ok := db.spaceByMeterToken(""); ok {
+ t.Fatalf("empty token resolved to space %d", sid)
+ }
+ _ = other
+ if code, _ := ingestUsage(t, s, "meter-tok", []botUsageRow{{Day: "bad", Dimension: "llm_requests", Quantity: 1}}); code != http.StatusBadRequest {
+ t.Fatal("invalid rows must be refused at the ledger too")
+ }
+}
+
+// The sweep mints a meter token for any space without one, pushes it through
+// controld, and records only the hash. A space that already has one is left
+// alone — tokens rotate only when a push failed to record.
+func TestMeterSweepMintsTokens(t *testing.T) {
+ db := openTestCloudDB(t)
+ id := insertSpaceRow(t, db, "fresh.11ch.at", 1000)
+
+ var pushes int
+ var gotURL, gotToken string
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": 1, "setup_complete": true, "members": 1})
+ })
+ mux.HandleFunc("POST /instances/{host}/meter", func(w http.ResponseWriter, r *http.Request) {
+ var body struct{ URL, Token string }
+ json.NewDecoder(r.Body).Decode(&body)
+ pushes++
+ gotURL, gotToken = body.URL, body.Token
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ })
+ sock := fakeControl(t, mux.ServeHTTP)
+ s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t",
+ usageURL: "https://cloud.example/api/usage"}
+
+ s.meterOnce()
+ if pushes != 1 || gotURL != "https://cloud.example/api/usage" || gotToken == "" {
+ t.Fatalf("push: n=%d url=%q token-empty=%v", pushes, gotURL, gotToken == "")
+ }
+ // The pushed token authenticates; only its hash is stored.
+ if sid, ok := db.spaceByMeterToken(gotToken); !ok || sid != id {
+ t.Fatalf("pushed token does not resolve: ok=%v sid=%d", ok, sid)
+ }
+ // Converged: the next sweep must not re-mint.
+ s.meterOnce()
+ if pushes != 1 {
+ t.Fatalf("second sweep re-minted (pushes=%d)", pushes)
+ }
+}
+
+// With the usage pipe off (no LCHAT_CLOUD_USAGE_URL) the sweep never touches
+// meter tokens — self-hosted cloud deployments see no behavior change.
+func TestMeterSweepNoUsageURL(t *testing.T) {
+ db := openTestCloudDB(t)
+ insertSpaceRow(t, db, "fresh.11ch.at", 1000)
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": 1})
+ })
+ mux.HandleFunc("POST /instances/{host}/meter", func(w http.ResponseWriter, r *http.Request) {
+ t.Error("meter push must not happen with the pipe off")
+ })
+ sock := fakeControl(t, mux.ServeHTTP)
+ s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t"}
+ s.meterOnce()
+}
diff --git a/controld.go b/controld.go
index 3fe772f..d5c0f67 100644
--- a/controld.go
+++ b/controld.go
@@ -106,6 +106,7 @@ func (c *controld) handler() http.Handler {
mux.HandleFunc("DELETE /instances/{host}", c.auth(c.deleteHandler))
mux.HandleFunc("GET /instances/{host}/stats", c.auth(c.statsHandler))
mux.HandleFunc("POST /instances/{host}/quota", c.auth(c.quotaHandler))
+ mux.HandleFunc("POST /instances/{host}/meter", c.auth(c.meterHandler))
mux.HandleFunc("POST /instances/{host}/replication", c.auth(c.replicationHandler))
mux.HandleFunc("POST /instances/{host}/materialize", c.auth(c.materializeHandler))
return mux
@@ -272,6 +273,57 @@ func (c *controld) quotaHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restarted": restarted})
}
+// meterHandler hands one instance its usage-pipe credentials (#857): upsert
+// LCHAT_METER_URL + LCHAT_METER_TOKEN in the env and bounce the unit so they
+// take effect — the same shape as quotaHandler, minus the registry-quota
+// bookkeeping. Both empty clears the pair (metering off).
+func (c *controld) meterHandler(w http.ResponseWriter, r *http.Request) {
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ if !validHost(host) {
+ writeControlErr(w, http.StatusBadRequest, "invalid_host", "not a valid hostname")
+ return
+ }
+ var req struct {
+ URL string `json:"url"`
+ Token string `json:"token"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil ||
+ (req.URL == "") != (req.Token == "") {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "url and token required together (both empty clears)")
+ return
+ }
+ envPath := filepath.Join(instanceDataDir, host+".env")
+ if err := setInstanceMeter(envPath, req.URL, req.Token); err != nil {
+ if os.IsNotExist(err) {
+ writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance")
+ return
+ }
+ log.Printf("controld: meter %s: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "meter_failed", err.Error())
+ return
+ }
+ // Root just rewrote the env; the router (service user) reads it at wake.
+ if err := matchOwner(envPath, instanceDataDir); err != nil {
+ log.Printf("controld: meter %s: env ownership: %v", host, err)
+ }
+ restarted := false
+ hibernating := false
+ if c.reg != nil {
+ if rt, err := c.reg.get(host); err == nil && rt != nil && rt.Hibernate {
+ hibernating = true // picks the env up on its next wake
+ }
+ }
+ if !hibernating {
+ if err := systemctl("try-restart", "lchat@"+host); err == nil {
+ restarted = true
+ } else {
+ log.Printf("controld: meter %s: restart: %v (applies on next start)", host, err)
+ }
+ }
+ log.Printf("controld: meter %s → %v (restarted=%v)", host, req.URL != "", restarted)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restarted": restarted})
+}
+
// statsHandler reports the reaper's guards for a space — setup_complete, member
// count, and has_content (any message or thread). Read-only against the live
// instance DB. validHost guards the DB path against traversal before it's joined
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index 8d67468..c4905e2 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -359,6 +359,31 @@ it. Unset = no enforcement, and operator-run instances (not in this ledger)
are never touched. Caps are sized to the box today (see
`tier-economics.md`); they rise when attachments move off-box (#550).
+## The usage pipe (#857, #668 slices 2–3) — AI usage into the ledger
+
+With `LCHAT_CLOUD_USAGE_URL` set (the cloud's own public `/api/usage` URL),
+the same meter sweep mints each ledger space a **meter token** and pushes it
+into the instance env (`LCHAT_METER_URL` + `LCHAT_METER_TOKEN`, via controld
+`POST /instances/{host}/meter`, hash-only in the ledger, same self-healing
+best-effort shape as the quota push). The flow is sidecar → instance → cloud:
+
+- The **sidecar** counts model spend — it's the only place spend is visible —
+ as (day, model, dimension) quantities: `llm_requests`, the four token
+ counters (input / output / cache-write / cache-read, priced differently),
+ and `charter_runs`. It flushes every few minutes to its instance
+ (`POST /api/bot-usage`, bot-token-gated), keeping its egress surface at the
+ two destinations `docs/bots.md` promises.
+- The **instance** is a stateless relay: validate, forward with the meter
+ token, hand the response's budget state back to the sidecar. Unconfigured
+ (self-hosted, BYO key) it accepts and drops — answer `"unmetered"`, nothing
+ recorded anywhere.
+- The **cloud** upserts into `usage(space_id, day, model, dimension,
+ quantity)` — quantities only, never content, never per-member attribution
+ (#668's rule) — and answers a budget state, `"ok"` unconditionally until
+ the phase-3 allowance/budget math lands (docs/ai-provisioning.md).
+
+Unset `LCHAT_CLOUD_USAGE_URL` = the pipe is off fleet-wide: no tokens minted,
+no env pushes, instances answer sidecars `"unmetered"`.
**Replication classes (the durability ladder).** With
`LCHAT_CLOUD_REPLICATION_CLASSES` set
(`free=daily,family=daily,community=batched,business=stream` — same key
diff --git a/internal/bot/anthropic.go b/internal/bot/anthropic.go
index d5902d4..9945dfe 100644
--- a/internal/bot/anthropic.go
+++ b/internal/bot/anthropic.go
@@ -25,16 +25,21 @@ type anthropicClient struct {
key string
base string
http *http.Client
+ // record, when set, receives every successful call's usage block — the
+ // usage meter's tap (#857). It sits here, not at the call sites, so every
+ // model path (reply, classifier, task, research, app build) is counted
+ // without each one remembering to.
+ record func(model string, u anthUsage)
}
-func newAnthropicClient(key string) *anthropicClient {
+func newAnthropicClient(key string, record func(model string, u anthUsage)) *anthropicClient {
base := os.Getenv("ANTHROPIC_BASE_URL")
if base == "" {
base = "https://api.anthropic.com"
}
// Long timeout: a thinking run over a large cached system prompt can
// legitimately take a couple of minutes.
- return &anthropicClient{key: key, base: base, http: &http.Client{Timeout: 5 * time.Minute}}
+ return &anthropicClient{key: key, base: base, http: &http.Client{Timeout: 5 * time.Minute}, record: record}
}
type anthBlock struct {
@@ -109,6 +114,18 @@ type anthRequest struct {
type anthResponse struct {
Content []anthBlock `json:"content"`
StopReason string `json:"stop_reason"`
+ Usage anthUsage `json:"usage"`
+}
+
+// anthUsage is the token accounting block every Messages response carries.
+// All four counters are kept distinct — cache reads and writes price
+// differently from fresh input, so the usage meter (#857) records each as its
+// own dimension.
+type anthUsage struct {
+ InputTokens int64 `json:"input_tokens"`
+ OutputTokens int64 `json:"output_tokens"`
+ CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
+ CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
}
// anthHTTPError is a non-200 response — carried as an error so create's retry
@@ -158,6 +175,9 @@ func (c *anthropicClient) do(req anthRequest, stream bool) (*anthResponse, error
resp, err = c.doRequest(body)
}
if err == nil {
+ if c.record != nil {
+ c.record(req.Model, resp.Usage)
+ }
return resp, nil
}
lastErr = err
@@ -256,6 +276,13 @@ func (c *anthropicClient) doStream(body []byte) (*anthResponse, error) {
PartialJSON string `json:"partial_json"`
StopReason string `json:"stop_reason"`
} `json:"delta"`
+ // Usage rides message_start (input + cache counters, inside the
+ // message envelope) and message_delta (cumulative output tokens,
+ // as a sibling of delta) — both are folded into out.Usage.
+ Message *struct {
+ Usage anthUsage `json:"usage"`
+ } `json:"message"`
+ Usage *anthUsage `json:"usage"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
@@ -265,6 +292,10 @@ func (c *anthropicClient) doStream(body []byte) (*anthResponse, error) {
continue
}
switch ev.Type {
+ case "message_start":
+ if ev.Message != nil {
+ out.Usage = ev.Message.Usage
+ }
case "content_block_start":
for len(blocks) <= ev.Index {
blocks = append(blocks, nil)
@@ -289,6 +320,22 @@ func (c *anthropicClient) doStream(body []byte) (*anthResponse, error) {
if ev.Delta != nil && ev.Delta.StopReason != "" {
out.StopReason = ev.Delta.StopReason
}
+ // message_delta usage counters are cumulative totals — take the
+ // latest non-zero values rather than summing.
+ if ev.Usage != nil {
+ if ev.Usage.OutputTokens > 0 {
+ out.Usage.OutputTokens = ev.Usage.OutputTokens
+ }
+ if ev.Usage.InputTokens > 0 {
+ out.Usage.InputTokens = ev.Usage.InputTokens
+ }
+ if ev.Usage.CacheCreationInputTokens > 0 {
+ out.Usage.CacheCreationInputTokens = ev.Usage.CacheCreationInputTokens
+ }
+ if ev.Usage.CacheReadInputTokens > 0 {
+ out.Usage.CacheReadInputTokens = ev.Usage.CacheReadInputTokens
+ }
+ }
case "error":
if ev.Error != nil {
return nil, fmt.Errorf("anthropic: stream error: %s: %s", ev.Error.Type, ev.Error.Message)
diff --git a/internal/bot/brain.go b/internal/bot/brain.go
index 6294dc5..84478ff 100644
--- a/internal/bot/brain.go
+++ b/internal/bot/brain.go
@@ -87,7 +87,7 @@ func (r *Runtime) loadConfig() {
// silent, and declareKind (which runs right after this) re-badges honestly.
if r.AIKey != "" && (c.AIEnabled == nil || *c.AIEnabled) {
if r.ai == nil {
- r.ai = newAnthropicClient(r.AIKey)
+ r.ai = newAnthropicClient(r.AIKey, r.recordUsage)
}
} else {
r.ai = nil
diff --git a/internal/bot/chartersched.go b/internal/bot/chartersched.go
index 8d17870..9b023db 100644
--- a/internal/bot/chartersched.go
+++ b/internal/bot/chartersched.go
@@ -260,6 +260,7 @@ func (r *Runtime) charterTick(selfID string, selfKey []byte, tp *tickPayload) {
default:
r.clearCharterFails(tp.Bot)
r.postTaskResult(c.Thread, key, c.Title, &c.Task, res.text, res.card)
+ r.meterInc("charter_runs", 1) // completed runs only — failures cost tokens but deliver nothing
}
}
diff --git a/internal/bot/connect_v2_test.go b/internal/bot/connect_v2_test.go
index b32b89f..e15522f 100644
--- a/internal/bot/connect_v2_test.go
+++ b/internal/bot/connect_v2_test.go
@@ -487,7 +487,7 @@ func TestConnectChipFailureGetsSpokenExplanation(t *testing.T) {
t.Setenv("ANTHROPIC_BASE_URL", claude.URL)
h := newConnectHarness(t)
- h.rt.ai = newAnthropicClient("test-key")
+ h.rt.ai = newAnthropicClient("test-key", nil)
h.rt.model = defaultModel
grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"})
diff --git a/internal/bot/meter.go b/internal/bot/meter.go
new file mode 100644
index 0000000..a6610e7
--- /dev/null
+++ b/internal/bot/meter.go
@@ -0,0 +1,192 @@
+// The usage meter (#857, docs/ai-provisioning.md phase 1). The sidecar is the
+// only place model spend is visible, so it counts here and reports upward:
+// token counters per (day, model) plus activity counters, flushed to the
+// *instance* (POST /api/bot-usage, bot-token-gated like charter-next-due)
+// rather than straight to the cloud. Via-instance keeps the sidecar's egress
+// surface at its two destinations (the instance + the model API — docs/
+// bots.md) and keeps the meter token in exactly one env file, the instance's;
+// the instance relays to the cloud ledger and relays the budget state back.
+//
+// Counters are quantities only — token counts, request counts, run counts —
+// never content, per the meter-only-what-ciphertext-exposes rule (#668). They
+// live in memory: a sidecar restart loses at most one flush interval, which
+// is acceptable for metering (billing-grade reconciliation is cloud-side).
+package bot
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "sync"
+ "time"
+)
+
+// UsageRow is one meter cell: a (day, model, dimension) quantity. Model is
+// empty for activity dimensions (charter_runs). Day is UTC — the ledger's
+// grain, deliberately coarse.
+type UsageRow struct {
+ Day string `json:"day"`
+ Model string `json:"model,omitempty"`
+ Dimension string `json:"dimension"`
+ Quantity int64 `json:"quantity"`
+}
+
+type meterKey struct{ day, model, dim string }
+
+type usageMeter struct {
+ mu sync.Mutex
+ rows map[meterKey]int64
+}
+
+func newUsageMeter() *usageMeter { return &usageMeter{rows: map[meterKey]int64{}} }
+
+func meterDay() string { return time.Now().UTC().Format("2006-01-02") }
+
+// addLLM records one Messages call's usage block, all four token counters
+// kept distinct (cache reads/writes price differently from fresh input), plus
+// the request count.
+func (m *usageMeter) addLLM(model string, u anthUsage) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ day := meterDay()
+ for dim, n := range map[string]int64{
+ "llm_requests": 1,
+ "llm_input_tokens": u.InputTokens,
+ "llm_output_tokens": u.OutputTokens,
+ "llm_cache_write_tokens": u.CacheCreationInputTokens,
+ "llm_cache_read_tokens": u.CacheReadInputTokens,
+ } {
+ if n > 0 {
+ m.rows[meterKey{day, model, dim}] += n
+ }
+ }
+}
+
+// inc records a model-less activity dimension (e.g. charter_runs).
+func (m *usageMeter) inc(dim string, n int64) {
+ if n <= 0 {
+ return
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.rows[meterKey{meterDay(), "", dim}] += n
+}
+
+// drain snapshots and clears the counters; the caller restores the snapshot
+// if the flush fails, so a transient error loses nothing.
+func (m *usageMeter) drain() []UsageRow {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if len(m.rows) == 0 {
+ return nil
+ }
+ out := make([]UsageRow, 0, len(m.rows))
+ for k, q := range m.rows {
+ out = append(out, UsageRow{Day: k.day, Model: k.model, Dimension: k.dim, Quantity: q})
+ }
+ m.rows = map[meterKey]int64{}
+ return out
+}
+
+// restore folds a failed flush's rows back into the live counters.
+func (m *usageMeter) restore(rows []UsageRow) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for _, r := range rows {
+ m.rows[meterKey{r.Day, r.Model, r.Dimension}] += r.Quantity
+ }
+}
+
+// recordUsage is the model client's accounting tap (its record hook).
+func (r *Runtime) recordUsage(model string, u anthUsage) {
+ if r.meter != nil {
+ r.meter.addLLM(model, u)
+ }
+}
+
+// meterInc bumps an activity dimension from anywhere in the runtime.
+func (r *Runtime) meterInc(dim string, n int64) {
+ if r.meter != nil {
+ r.meter.inc(dim, n)
+ }
+}
+
+// meterFlushEvery paces reporting. Coarse on purpose: the ledger's grain is a
+// day, so nothing downstream needs freshness — this only bounds how much a
+// crash can lose.
+const meterFlushEvery = 5 * time.Minute
+
+// usageReport is one flush in flight: drained rows plus the id the ledger
+// dedupes replays on. A failed flush keeps the report VERBATIM and resends
+// it — never re-drains into it — because "failed" includes "the cloud
+// committed but the ack got lost", and only a byte-identical replay under
+// the same id lets the ledger recognize and drop the duplicate.
+type usageReport struct {
+ ID string `json:"report_id"`
+ Rows []UsageRow `json:"rows"`
+}
+
+func newReportID() string {
+ raw := make([]byte, 16)
+ if _, err := rand.Read(raw); err != nil {
+ // Out of entropy is unrecoverable weirdness; a constant would break
+ // dedupe silently, so fail the report instead (retried next tick).
+ return ""
+ }
+ return hex.EncodeToString(raw)
+}
+
+func (r *Runtime) meterLoop(ctx context.Context) {
+ t := time.NewTicker(meterFlushEvery)
+ defer t.Stop()
+ for {
+ select {
+ case <-t.C:
+ r.flushUsage()
+ case <-ctx.Done():
+ // Best-effort final flush — the graceful paths (sleep co-spawn
+ // SIGTERM, deploy restart) shouldn't drop their last interval.
+ r.flushUsage()
+ return
+ }
+ }
+}
+
+// flushUsage reports counters to the instance and remembers the budget state
+// the response carries (inert today; phase 3 gates the model path on it).
+// Runs only on the meterLoop goroutine, so pendingUsage needs no lock: a
+// pending report is retried as-is before anything new is drained, and fresh
+// counts keep accumulating in the meter for the report after it.
+func (r *Runtime) flushUsage() {
+ if r.meter == nil {
+ return
+ }
+ if r.pendingUsage == nil {
+ rows := r.meter.drain()
+ if len(rows) == 0 {
+ return
+ }
+ id := newReportID()
+ if id == "" {
+ r.meter.restore(rows)
+ return
+ }
+ r.pendingUsage = &usageReport{ID: id, Rows: rows}
+ }
+ var resp struct {
+ OK bool `json:"ok"`
+ Budget struct {
+ State string `json:"state"`
+ } `json:"budget"`
+ }
+ if err := r.post("/api/bot-usage", map[string]any{
+ "token": r.Session.Token, "report_id": r.pendingUsage.ID, "rows": r.pendingUsage.Rows,
+ }, &resp); err != nil {
+ r.logf("usage: flush failed (%v) — report retried next tick", err)
+ return
+ }
+ r.pendingUsage = nil
+ if resp.Budget.State != "" {
+ r.budgetState.Store(resp.Budget.State)
+ }
+}
diff --git a/internal/bot/meter_test.go b/internal/bot/meter_test.go
new file mode 100644
index 0000000..515e312
--- /dev/null
+++ b/internal/bot/meter_test.go
@@ -0,0 +1,190 @@
+package bot
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func rowsByKey(rows []UsageRow) map[string]int64 {
+ out := map[string]int64{}
+ for _, r := range rows {
+ out[r.Day+"|"+r.Model+"|"+r.Dimension] = r.Quantity
+ }
+ return out
+}
+
+// The accumulator's contract: calls merge into (day, model, dimension)
+// cells, drain empties, and restore folds a failed flush back in losslessly.
+func TestUsageMeterAccumulateDrainRestore(t *testing.T) {
+ m := newUsageMeter()
+ m.addLLM("claude-sonnet-5", anthUsage{InputTokens: 100, OutputTokens: 20, CacheReadInputTokens: 300})
+ m.addLLM("claude-sonnet-5", anthUsage{InputTokens: 50, OutputTokens: 5, CacheCreationInputTokens: 7})
+ m.addLLM("claude-haiku-4-5", anthUsage{InputTokens: 9})
+ m.inc("charter_runs", 1)
+
+ day := meterDay()
+ rows := m.drain()
+ got := rowsByKey(rows)
+ for key, want := range map[string]int64{
+ day + "|claude-sonnet-5|llm_requests": 2,
+ day + "|claude-sonnet-5|llm_input_tokens": 150,
+ day + "|claude-sonnet-5|llm_output_tokens": 25,
+ day + "|claude-sonnet-5|llm_cache_read_tokens": 300,
+ day + "|claude-sonnet-5|llm_cache_write_tokens": 7,
+ day + "|claude-haiku-4-5|llm_requests": 1,
+ day + "|claude-haiku-4-5|llm_input_tokens": 9,
+ day + "||charter_runs": 1,
+ } {
+ if got[key] != want {
+ t.Errorf("%s = %d, want %d", key, got[key], want)
+ }
+ }
+ if m.drain() != nil {
+ t.Fatal("second drain should be empty")
+ }
+
+ // A failed flush restores; new counts merge on top.
+ m.restore(rows)
+ m.addLLM("claude-sonnet-5", anthUsage{InputTokens: 1})
+ again := rowsByKey(m.drain())
+ if again[day+"|claude-sonnet-5|llm_input_tokens"] != 151 {
+ t.Fatalf("restore+add did not merge: %v", again)
+ }
+}
+
+// A zero-usage block (a failed parse, an empty response) records only the
+// request count — never zero-quantity rows.
+func TestUsageMeterSkipsZeroes(t *testing.T) {
+ m := newUsageMeter()
+ m.addLLM("m", anthUsage{})
+ rows := m.drain()
+ if len(rows) != 1 || rows[0].Dimension != "llm_requests" || rows[0].Quantity != 1 {
+ t.Fatalf("want just llm_requests=1, got %+v", rows)
+ }
+}
+
+// The non-streamed path: the response's usage block reaches both the returned
+// response and the record hook (the meter's tap).
+func TestAnthropicUsageParsed(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "content": []map[string]any{{"type": "text", "text": "hi"}},
+ "stop_reason": "end_turn",
+ "usage": map[string]any{
+ "input_tokens": 120, "output_tokens": 34,
+ "cache_creation_input_tokens": 8, "cache_read_input_tokens": 4000,
+ },
+ })
+ }))
+ defer srv.Close()
+
+ var gotModel string
+ var got anthUsage
+ c := &anthropicClient{key: "k", base: srv.URL, http: srv.Client(),
+ record: func(model string, u anthUsage) { gotModel, got = model, u }}
+ resp, err := c.create(anthRequest{Model: "claude-sonnet-5", MaxTokens: 10})
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ want := anthUsage{InputTokens: 120, OutputTokens: 34, CacheCreationInputTokens: 8, CacheReadInputTokens: 4000}
+ if resp.Usage != want {
+ t.Fatalf("response usage = %+v, want %+v", resp.Usage, want)
+ }
+ if gotModel != "claude-sonnet-5" || got != want {
+ t.Fatalf("record hook got (%q, %+v)", gotModel, got)
+ }
+}
+
+// The streamed path: input + cache counters ride message_start, the final
+// (cumulative) output count rides message_delta.
+func TestAnthropicUsageParsedStream(t *testing.T) {
+ stream := "event: message_start\n" +
+ `data: {"type":"message_start","message":{"usage":{"input_tokens":200,"output_tokens":1,"cache_read_input_tokens":900}}}` + "\n\n" +
+ "event: content_block_start\n" +
+ `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + "\n\n" +
+ "event: content_block_delta\n" +
+ `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}` + "\n\n" +
+ "event: message_delta\n" +
+ `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":57}}` + "\n\n" +
+ "event: message_stop\n" +
+ `data: {"type":"message_stop"}` + "\n\n"
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Write([]byte(stream))
+ }))
+ defer srv.Close()
+
+ var got anthUsage
+ c := &anthropicClient{key: "k", base: srv.URL, http: srv.Client(),
+ record: func(_ string, u anthUsage) { got = u }}
+ resp, err := c.createStream(anthRequest{Model: "m", MaxTokens: 10})
+ if err != nil {
+ t.Fatalf("createStream: %v", err)
+ }
+ want := anthUsage{InputTokens: 200, OutputTokens: 57, CacheReadInputTokens: 900}
+ if resp.Usage != want {
+ t.Fatalf("stream usage = %+v, want %+v", resp.Usage, want)
+ }
+ if got != want {
+ t.Fatalf("record hook got %+v", got)
+ }
+}
+
+// The outbox contract (the ack-loss fix): a failed flush keeps the report
+// VERBATIM — same id, same rows — so the ledger can dedupe the replay, and
+// usage counted during the outage rides the NEXT report, never the pending one.
+func TestFlushUsageOutboxRetriesVerbatim(t *testing.T) {
+ type report struct {
+ ReportID string `json:"report_id"`
+ Rows []UsageRow `json:"rows"`
+ }
+ var got []report
+ fail := true
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var rep report
+ json.NewDecoder(r.Body).Decode(&rep)
+ got = append(got, rep)
+ if fail {
+ http.Error(w, "cloud blip", http.StatusBadGateway)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"ok": true, "budget": map[string]any{"state": "ok"}})
+ }))
+ defer srv.Close()
+
+ r := &Runtime{Base: srv.URL, Session: &Session{Token: "tok", Name: "Eleven"}, Logf: t.Logf}
+ r.client = srv.Client()
+ r.meter = newUsageMeter()
+
+ r.meter.inc("charter_runs", 1)
+ r.flushUsage() // fails; report stays pending
+ r.meter.inc("charter_runs", 5)
+ r.flushUsage() // still failing; the SAME report is resent, new counts stay in the meter
+ fail = false
+ r.flushUsage() // succeeds: pending report lands
+ r.flushUsage() // then the accumulated counts land as a NEW report
+
+ if len(got) != 4 {
+ t.Fatalf("attempts = %d, want 4", len(got))
+ }
+ if got[0].ReportID == "" || got[0].ReportID != got[1].ReportID || got[1].ReportID != got[2].ReportID {
+ t.Fatalf("retries must reuse the report id: %q %q %q", got[0].ReportID, got[1].ReportID, got[2].ReportID)
+ }
+ if got[3].ReportID == got[2].ReportID {
+ t.Fatal("post-success flush must mint a fresh report id")
+ }
+ if len(got[2].Rows) != 1 || got[2].Rows[0].Quantity != 1 {
+ t.Fatalf("pending report mutated across retries: %+v", got[2].Rows)
+ }
+ if len(got[3].Rows) != 1 || got[3].Rows[0].Quantity != 5 {
+ t.Fatalf("outage-time counts must ride the next report: %+v", got[3].Rows)
+ }
+ if r.pendingUsage != nil {
+ t.Fatal("nothing should be pending after success")
+ }
+ if r.budgetState.Load() != "ok" {
+ t.Fatalf("budget state not stored: %v", r.budgetState.Load())
+ }
+}
diff --git a/internal/bot/research_test.go b/internal/bot/research_test.go
index 0b52d44..86acbc2 100644
--- a/internal/bot/research_test.go
+++ b/internal/bot/research_test.go
@@ -83,7 +83,7 @@ func TestResearchEndToEnd(t *testing.T) {
`{"content":[{"type":"text","text":"You have one repo: paul/lchat."}],"stop_reason":"end_turn"}`,
)
h := newConnectHarness(t)
- h.rt.ai = newAnthropicClient("test-key")
+ h.rt.ai = newAnthropicClient("test-key", nil)
grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_research", "exp": time.Now().Add(time.Minute).UnixMilli(), "access": "read"})
h.rt.handleResearch("t1", h.key, &researchPayload{
@@ -138,7 +138,7 @@ func TestResearchEndToEnd(t *testing.T) {
func TestResearchForeignGrantStaysSilent(t *testing.T) {
newFakeGitHub(t, `[]`)
h := newConnectHarness(t)
- h.rt.ai = newAnthropicClient("test-key")
+ h.rt.ai = newAnthropicClient("test-key", nil)
other, err := e2ee.GenerateUserKeypair()
if err != nil {
t.Fatal(err)
@@ -160,7 +160,7 @@ func TestResearchExpiredGrantNeverReachesProvider(t *testing.T) {
`{"content":[{"type":"text","text":"That look expired."}],"stop_reason":"end_turn"}`,
)
h := newConnectHarness(t)
- h.rt.ai = newAnthropicClient("test-key")
+ h.rt.ai = newAnthropicClient("test-key", nil)
grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x",
"exp": time.Now().Add(-time.Minute).UnixMilli(), "access": "read"})
h.rt.handleResearch("t1", h.key, &researchPayload{
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go
index 2621b51..7030cfc 100644
--- a/internal/bot/runtime.go
+++ b/internal/bot/runtime.go
@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/coder/websocket"
@@ -51,14 +52,23 @@ type Runtime struct {
// charter would run once per host (botcmd elects it).
Charters bool
- client *http.Client
- ai *anthropicClient
- connect *connectConfig // connected-services (docs/connected-services.md)
- keys map[string]threadEntry
- cursors map[string]int64 // threadId -> last message id we've processed
- agents map[string]bool // roster labels of ALL agents — never reply to one
- mention *regexp.Regexp
- bare *regexp.Regexp // bare-name mention, for listen mode "name"
+ client *http.Client
+ ai *anthropicClient
+ meter *usageMeter // model-spend accounting, flushed to the instance (#857, meter.go)
+ // pendingUsage is the one usage report in flight — kept verbatim across
+ // failed flushes so the ledger can dedupe a replay whose first ack was
+ // lost. Touched only on the meterLoop goroutine.
+ pendingUsage *usageReport
+ // budgetState is the space's AI budget as of the last usage flush ("ok",
+ // "unmetered", …). Inert today — phase 3 (docs/ai-provisioning.md) gates
+ // the model path on it.
+ budgetState atomic.Value
+ connect *connectConfig // connected-services (docs/connected-services.md)
+ keys map[string]threadEntry
+ cursors map[string]int64 // threadId -> last message id we've processed
+ agents map[string]bool // roster labels of ALL agents — never reply to one
+ mention *regexp.Regexp
+ bare *regexp.Regexp // bare-name mention, for listen mode "name"
// Console-managed config (loadConfig, re-fetched each connect).
persona string
@@ -254,10 +264,14 @@ func (r *Runtime) Run(ctx context.Context) error {
r.charterHomes = map[string]charterHome{}
r.mention = regexp.MustCompile(`(?i)(^|[^a-z0-9])@` + regexp.QuoteMeta(r.Session.Name) + `\b`)
r.bare = regexp.MustCompile(`(?i)(^|[^a-z0-9])` + regexp.QuoteMeta(r.Session.Name) + `\b`)
+ r.meter = newUsageMeter()
if r.AIKey != "" {
- r.ai = newAnthropicClient(r.AIKey)
+ r.ai = newAnthropicClient(r.AIKey, r.recordUsage)
}
r.connect = loadConnectConfig()
+ // The usage meter flushes on its own clock, independent of connection
+ // state — a failed flush restores the counters and the next tick retries.
+ go r.meterLoop(ctx)
if r.Charters {
// The slow reconcile loop: heals lost ticks, drops alarms for deleted
// charters, and arms charters that appeared while we were connected.
diff --git a/internal/bot/task_test.go b/internal/bot/task_test.go
index 90a52a2..bf36797 100644
--- a/internal/bot/task_test.go
+++ b/internal/bot/task_test.go
@@ -39,7 +39,7 @@ func TestExecTaskRetriesWithoutThinking(t *testing.T) {
defer srv.Close()
t.Setenv("ANTHROPIC_BASE_URL", srv.URL)
- r := &Runtime{Session: &Session{Name: "Eleven"}, ai: newAnthropicClient("test-key"), Logf: t.Logf}
+ r := &Runtime{Session: &Session{Name: "Eleven"}, ai: newAnthropicClient("test-key", nil), Logf: t.Logf}
res, err := r.execTask("Test Bot", &TaskSpec{Prompt: "write the edition"})
if err != nil {
t.Fatal(err)
diff --git a/main.go b/main.go
index d8a7076..c20c7b4 100644
--- a/main.go
+++ b/main.go
@@ -92,6 +92,11 @@ type Server struct {
apns apnsSender // nil unless APNs (direct p8 or relay) is configured
maxUpload int64 // per-upload attachment cap (LCHAT_MAX_UPLOAD_BYTES)
blobQuota int64 // per-instance total blob-bytes ceiling (LCHAT_BLOB_QUOTA_BYTES)
+ // The usage-pipe relay (usage.go, #857): where sidecar usage reports go
+ // and the per-space meter token that authenticates them. Pushed into the
+ // env by the provisioner; empty = unmetered (self-hosted / BYO key).
+ meterURL string // LCHAT_METER_URL
+ meterToken string // LCHAT_METER_TOKEN
// Directory of sideloaded mini-app modules served at /static/apps-custom/
// (LCHAT_APPS_DIR); empty disables the feature. See customapps.go.
appsDir string
@@ -274,7 +279,9 @@ func main() {
entitlement: strings.TrimSpace(os.Getenv("LCHAT_ENTITLEMENT")),
maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes),
blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes),
- appsDir: strings.TrimSpace(os.Getenv("LCHAT_APPS_DIR"))}
+ meterURL: strings.TrimSpace(os.Getenv("LCHAT_METER_URL")),
+ meterToken: strings.TrimSpace(os.Getenv("LCHAT_METER_TOKEN")),
+ appsDir: strings.TrimSpace(os.Getenv("LCHAT_APPS_DIR"))}
s.brakes = newModBrakes()
// Tamper evidence (checkpoint.go): by default the signing seed lives in
@@ -415,6 +422,7 @@ func (s *Server) handler() http.Handler {
// The charter host's wake-time report (bot-token-gated; folded into
// GET /api/next-due so hibernated charters still fire on time).
mux.HandleFunc("POST /api/charter-next-due", s.postCharterNextDue)
+ mux.HandleFunc("POST /api/bot-usage", s.postBotUsage)
// The sandbox frame document: an opaque-origin cage for member-app code.
mux.HandleFunc("GET /app-sandbox", s.serveAppSandbox)
diff --git a/meter.go b/meter.go
index f869f76..b56a654 100644
--- a/meter.go
+++ b/meter.go
@@ -24,6 +24,7 @@ import (
"path/filepath"
"strconv"
"strings"
+ "sync"
"time"
)
@@ -92,6 +93,13 @@ func (s *cloudServer) meterOnce() (metered int) {
return 0
}
now := time.Now().Unix()
+ if s.usageURL != "" {
+ // Applied-report dedupe ids age out with the sweep (usage pipe, #857);
+ // the sidecar retry horizon is minutes, so a week is generous.
+ if err := s.db.pruneUsageReports(now - 7*24*3600); err != nil {
+ log.Printf("cloud: meter: usage-report prune: %v", err)
+ }
+ }
for _, sp := range spaces {
st, err := s.controlStats(sp.Host)
if err != nil {
@@ -106,6 +114,11 @@ func (s *cloudServer) meterOnce() (metered int) {
if want := s.capFor(sp.Entitlement, sp.Tier); want > 0 && want != sp.QuotaBytes {
s.pushQuota(sp.ID, sp.Host, sp.Entitlement, sp.Tier)
}
+ // The usage pipe (#857): any space without a meter token gets one —
+ // how the fleet converges without a per-space backfill step.
+ if s.usageURL != "" && sp.MeterTokenHash == "" {
+ s.pushMeterToken(sp.ID, sp.Host)
+ }
// Replication class reconcile (cloudreplication.go): no ledger record
// to compare, so push each pass — controld no-ops on a matching class.
s.pushReplication(sp.Host, sp.Entitlement, sp.Tier)
@@ -227,27 +240,61 @@ func entitlementLabel(entitlement, tier string) string {
// ---- quota apply (controld-side, root) -------------------------------------------
-// setInstanceQuota upserts LCHAT_BLOB_QUOTA_BYTES in an instance's env file
-// (0 removes it — back to the compiled default) and, when entitlement is
-// non-empty, mirrors LCHAT_ENTITLEMENT ("free"/"paid" — the UI's billing-
-// affordance gate) in the same write, preserving every other var. Pure but
-// for the filesystem, so it's directly testable.
-func setInstanceQuota(envPath string, quota int64, entitlement string) error {
+// instanceEnvMu serializes every read-modify-write of instance env files.
+// controld's quota and meter pushes arrive on separate request goroutines
+// (a Stripe webhook and the sweep can land together) and write DIFFERENT
+// keys to the SAME file — unserialized, the loser's read-before-write
+// snapshot would silently drop the winner's keys, and because both callers
+// record success in the ledger, nothing would ever retry. One process-wide
+// mutex is plenty at this scale; per-host granularity would buy nothing.
+var instanceEnvMu sync.Mutex
+
+// updateInstanceEnv applies one mutation to an env file under the lock —
+// the only correct way to touch an instance env after the daemon is up.
+func updateInstanceEnv(envPath string, mutate func(env map[string]string)) error {
+ instanceEnvMu.Lock()
+ defer instanceEnvMu.Unlock()
env, err := readEnvFile(envPath)
if err != nil {
return err
}
- if quota <= 0 {
- delete(env, "LCHAT_BLOB_QUOTA_BYTES")
- } else {
- env["LCHAT_BLOB_QUOTA_BYTES"] = strconv.FormatInt(quota, 10)
- }
- if entitlement != "" {
- env["LCHAT_ENTITLEMENT"] = entitlement
- }
+ mutate(env)
return writeEnvFile(envPath, env)
}
+// setInstanceQuota upserts LCHAT_BLOB_QUOTA_BYTES in an instance's env file
+// (0 removes it — back to the compiled default) and, when entitlement is
+// non-empty, mirrors LCHAT_ENTITLEMENT ("free"/"paid" — the UI's billing-
+// affordance gate) in the same write, preserving every other var.
+func setInstanceQuota(envPath string, quota int64, entitlement string) error {
+ return updateInstanceEnv(envPath, func(env map[string]string) {
+ if quota <= 0 {
+ delete(env, "LCHAT_BLOB_QUOTA_BYTES")
+ } else {
+ env["LCHAT_BLOB_QUOTA_BYTES"] = strconv.FormatInt(quota, 10)
+ }
+ if entitlement != "" {
+ env["LCHAT_ENTITLEMENT"] = entitlement
+ }
+ })
+}
+
+// setInstanceMeter upserts LCHAT_METER_URL + LCHAT_METER_TOKEN in an
+// instance's env file (both empty removes the pair — metering off),
+// preserving every other var. The controld half of the meter-token push,
+// next to its quota sibling.
+func setInstanceMeter(envPath, url, token string) error {
+ return updateInstanceEnv(envPath, func(env map[string]string) {
+ if url == "" {
+ delete(env, "LCHAT_METER_URL")
+ delete(env, "LCHAT_METER_TOKEN")
+ } else {
+ env["LCHAT_METER_URL"] = url
+ env["LCHAT_METER_TOKEN"] = token
+ }
+ })
+}
+
// pushQuotaByID looks a space's billing state up and applies its cap —
// the webhook-side entry point (events carry ids, not rows).
func (s *cloudServer) pushQuotaByID(id int64) {
diff --git a/meter_test.go b/meter_test.go
index f0fb416..9dd2136 100644
--- a/meter_test.go
+++ b/meter_test.go
@@ -5,6 +5,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "sync"
"testing"
"time"
)
@@ -231,3 +232,40 @@ func TestQuotaPushOnUpgradeAndSweep(t *testing.T) {
t.Errorf("unconfigured caps pushed: %v", pushed2)
}
}
+
+// The quota and meter pushes write DIFFERENT keys to the SAME env file from
+// separate request goroutines; updateInstanceEnv serializes them so neither
+// side's read-before-write snapshot can drop the other's keys. Hammered
+// concurrently, every key family must survive.
+func TestInstanceEnvWritesSerialized(t *testing.T) {
+ envPath := filepath.Join(t.TempDir(), "space.env")
+ if err := writeEnvFile(envPath, map[string]string{"LCHAT_ADMIN_TOKEN": "keepme"}); err != nil {
+ t.Fatal(err)
+ }
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ if err := setInstanceQuota(envPath, 12345, "free"); err != nil {
+ t.Error(err)
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ if err := setInstanceMeter(envPath, "https://cloud.example/api/usage", "tok"); err != nil {
+ t.Error(err)
+ }
+ }()
+ }
+ wg.Wait()
+ env, err := readEnvFile(envPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"LCHAT_ADMIN_TOKEN", "LCHAT_BLOB_QUOTA_BYTES", "LCHAT_METER_URL", "LCHAT_METER_TOKEN"} {
+ if env[key] == "" {
+ t.Errorf("%s lost in concurrent pushes: %v", key, env)
+ }
+ }
+}
diff --git a/usage.go b/usage.go
new file mode 100644
index 0000000..6436919
--- /dev/null
+++ b/usage.go
@@ -0,0 +1,151 @@
+// 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"
+ "net/http"
+ "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})
+}
+
+// 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
+}
diff --git a/usage_test.go b/usage_test.go
new file mode 100644
index 0000000..41be643
--- /dev/null
+++ b/usage_test.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func postUsage(t *testing.T, base, token string, rows []botUsageRow) (int, map[string]any) {
+ t.Helper()
+ return postUsageReport(t, base, token, "aaaaaaaaaaaaaaaa", rows)
+}
+
+func postUsageReport(t *testing.T, base, token, reportID string, rows []botUsageRow) (int, map[string]any) {
+ t.Helper()
+ raw, _ := json.Marshal(map[string]any{"token": token, "report_id": reportID, "rows": rows})
+ resp, err := http.Post(base+"/api/bot-usage", "application/json", bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var body map[string]any
+ json.NewDecoder(resp.Body).Decode(&body)
+ return resp.StatusCode, body
+}
+
+// The relay end to end: a bot's report is validated, forwarded to the cloud
+// meter with the instance's bearer token, and the cloud's budget state comes
+// back to the sidecar untouched.
+func TestBotUsageRelay(t *testing.T) {
+ ts, s := newTestServer(t)
+ agent := provisionNamed(t, ts.URL, "Eleven", true)
+
+ var gotAuth string
+ var gotRows []botUsageRow
+ cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ var body struct {
+ Rows []botUsageRow `json:"rows"`
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+ gotRows = body.Rows
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": map[string]any{"state": "low"}})
+ }))
+ defer cloud.Close()
+ s.meterURL, s.meterToken = cloud.URL, "meter-tok"
+
+ rows := []botUsageRow{
+ {Day: "2026-07-14", Model: "claude-sonnet-5", Dimension: "llm_input_tokens", Quantity: 150},
+ {Day: "2026-07-14", Dimension: "charter_runs", Quantity: 1},
+ }
+ code, body := postUsage(t, ts.URL, agent.Token, rows)
+ if code != http.StatusOK {
+ t.Fatalf("relay: %d %v", code, body)
+ }
+ if gotAuth != "Bearer meter-tok" {
+ t.Fatalf("cloud auth = %q", gotAuth)
+ }
+ if len(gotRows) != 2 || gotRows[0].Quantity != 150 {
+ t.Fatalf("cloud rows = %+v", gotRows)
+ }
+ if budget, _ := body["budget"].(map[string]any); budget["state"] != "low" {
+ t.Fatalf("budget not passed through: %v", body)
+ }
+}
+
+// Unconfigured (self-hosted / BYO key): accept, drop, and say "unmetered".
+func TestBotUsageUnmetered(t *testing.T) {
+ ts, _ := newTestServer(t)
+ agent := provisionNamed(t, ts.URL, "Eleven", true)
+ code, body := postUsage(t, ts.URL, agent.Token, []botUsageRow{
+ {Day: "2026-07-14", Dimension: "llm_requests", Quantity: 1}})
+ if code != http.StatusOK {
+ t.Fatalf("unmetered: %d %v", code, body)
+ }
+ if budget, _ := body["budget"].(map[string]any); budget["state"] != "unmetered" {
+ t.Fatalf("want unmetered, got %v", body)
+ }
+}
+
+// The gate: bots only, and malformed rows never leave the instance.
+func TestBotUsageGateAndValidation(t *testing.T) {
+ ts, s := newTestServer(t)
+ alice := provisionNamed(t, ts.URL, "Alice", false)
+ agent := provisionNamed(t, ts.URL, "Eleven", true)
+ s.meterURL, s.meterToken = "http://cloud.invalid", "tok" // must never be dialed below
+
+ good := []botUsageRow{{Day: "2026-07-14", Dimension: "llm_requests", Quantity: 1}}
+ if code, _ := postUsage(t, ts.URL, alice.Token, good); code != http.StatusForbidden {
+ t.Fatalf("member token: want 403, got %d", code)
+ }
+ if code, _ := postUsage(t, ts.URL, "nonsense", good); code != http.StatusForbidden {
+ t.Fatalf("stranger: want 403, got %d", code)
+ }
+ for name, rows := range map[string][]botUsageRow{
+ "empty": {},
+ "bad day": {{Day: "yesterday", Dimension: "llm_requests", Quantity: 1}},
+ "bad dimension": {{Day: "2026-07-14", Dimension: "plaintext_words", Quantity: 1}},
+ "bad quantity": {{Day: "2026-07-14", Dimension: "llm_requests", Quantity: 0}},
+ } {
+ if code, _ := postUsage(t, ts.URL, agent.Token, rows); code != http.StatusBadRequest {
+ t.Fatalf("%s: want 400, got %d", name, code)
+ }
+ }
+ // A report without a well-formed dedupe id is refused too.
+ if code, _ := postUsageReport(t, ts.URL, agent.Token, "not hex!", good); code != http.StatusBadRequest {
+ t.Fatal("bad report id: want 400")
+ }
+}
+
+// A cloud blip is a 502 — the sidecar keeps its counters and retries.
+func TestBotUsageCloudDown(t *testing.T) {
+ ts, s := newTestServer(t)
+ agent := provisionNamed(t, ts.URL, "Eleven", true)
+ dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ dead.Close() // connection refused from here on
+ s.meterURL, s.meterToken = dead.URL, "tok"
+
+ code, _ := postUsage(t, ts.URL, agent.Token, []botUsageRow{
+ {Day: "2026-07-14", Dimension: "llm_requests", Quantity: 1}})
+ if code != http.StatusBadGateway {
+ t.Fatalf("cloud down: want 502, got %d", code)
+ }
+}