eleven / messenger Public

AI budget: allowance + capped pool + the sidecar's honest exhaustion gate (HELD for review) (#879)

* bot+cloud: AI budget machinery — allowance, pool, exhaustion gate

* cloud+bot: AI budget — allowance, fleet pool, exhaustion gate (phase 3 machinery)

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ab2d9ed16e718da7bc61941c6011883357df4e3e parent c7e81f5
13 files changed, +339 −13
  • cloud.go +17 −0
  • cloud_budget.go +116 −0
  • cloud_budget_test.go +94 −0
  • cloud_db.go +27 −0
  • cloud_usage.go +4 −4
  • internal/bot/appbuild.go +1 −1
  • internal/bot/appbuild_test.go +5 −5
  • internal/bot/brain.go +10 −0
  • internal/bot/brain_test.go +3 −3
  • internal/bot/meter.go +22 −0
  • internal/bot/meter_test.go +33 −0
  • internal/bot/runtime.go +2 −0
  • internal/bot/task.go +5 −0
diff --git a/cloud.go b/cloud.go
index 9e75bb8..f147f4e 100644
--- a/cloud.go
+++ b/cloud.go
@@ -94,6 +94,12 @@ type cloudServer struct {
// sidecar usage reports can flow (#857). Empty = the usage pipe is off.
usageURL string
pool poolConfig // free-pool gauge (pool.go); maxShare 0 = off
+ // The AI budget (cloud_budget.go, phase 3): per-model EUR rates, the
+ // per-space monthly allowance, and the fleet-wide monthly pool. All three
+ // unset = measure-only (every flush answers "ok").
+ aiRates map[string]aiRates
+ aiAllowanceEUR float64 // LCHAT_CLOUD_AI_ALLOWANCE_EUR
+ aiPoolEUR float64 // LCHAT_CLOUD_AI_POOL_EUR
}
func (s *cloudServer) handler() http.Handler {
@@ -539,6 +545,17 @@ func runCloud(args []string) {
if s.usageURL != "" {
log.Printf("cloud: usage pipe on — meter tokens push %s", s.usageURL)
}
+ aiRatesCfg, err := parseAIRates(strings.TrimSpace(os.Getenv("LCHAT_CLOUD_AI_RATES")))
+ if err != nil {
+ die("cloud: %v", err)
+ }
+ s.aiRates = aiRatesCfg
+ s.aiAllowanceEUR = envFloat("LCHAT_CLOUD_AI_ALLOWANCE_EUR", 0)
+ s.aiPoolEUR = envFloat("LCHAT_CLOUD_AI_POOL_EUR", 0)
+ if len(s.aiRates) > 0 && s.aiAllowanceEUR > 0 {
+ log.Printf("cloud: AI budget on — allowance €%.2f/space/month, pool €%.2f/month (%d models priced)",
+ s.aiAllowanceEUR, s.aiPoolEUR, len(s.aiRates))
+ }
if *createBudget > 0 {
log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget)
}
diff --git a/cloud_budget.go b/cloud_budget.go
new file mode 100644
index 0000000..1584d14
--- /dev/null
+++ b/cloud_budget.go
@@ -0,0 +1,116 @@
+// cloud_budget.go — phase 3 of docs/ai-provisioning.md: the allowance and
+// pool math that turns the usage ledger (#857) into a budget answer. Money
+// only ever appears here as CONFIG (rates, allowance, pool — env, set by the
+// operator) and as arithmetic over the ledger's quantities; no payment
+// machinery lives in this file. The forwarding of over-allowance usage to
+// Stripe Billing Meters is deliberately NOT built yet — it lands with #859's
+// AI add-on product, after the rates and allowance below are reviewed and
+// published in docs/tier-economics.md.
+//
+// The budget model, per the locked decisions (Paul 2026-07-14):
+// - Every space gets a small included monthly allowance (in EUR of model
+// cost, computed from per-model rates over the four token counters).
+// - A fleet-wide monthly pool bounds the aggregate subsidy: however many
+// free spaces opt in, the total included spend has a ceiling — the
+// creates_log circuit-breaker pattern, applied to tokens.
+// - Exhausted (space over allowance, or pool drained) means the sidecar
+// drops to the deterministic command floor with one honest notice
+// (internal/bot/meter.go); nothing breaks, nothing is deleted.
+// - Unconfigured = measure-only: every report answers "ok", exactly the
+// phase-1 behavior. BYO-key and self-hosted sidecars never report at all.
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "time"
+)
+
+// aiRates prices one model's tokens, EUR per million, one rate per counter —
+// cache reads and writes price differently from fresh input, which is why the
+// ledger keeps all four dimensions distinct.
+type aiRates struct {
+ In float64 `json:"in"`
+ Out float64 `json:"out"`
+ CacheWrite float64 `json:"cw"`
+ CacheRead float64 `json:"cr"`
+}
+
+// parseAIRates reads LCHAT_CLOUD_AI_RATES — a JSON object keyed by model id,
+// e.g. {"claude-sonnet-5":{"in":3,"out":15,"cw":3.75,"cr":0.3}}. Rates are
+// EUR per million tokens. Empty = no pricing = the budget never bites.
+func parseAIRates(v string) (map[string]aiRates, error) {
+ if v == "" {
+ return nil, nil
+ }
+ rates := map[string]aiRates{}
+ if err := json.Unmarshal([]byte(v), &rates); err != nil {
+ return nil, fmt.Errorf("bad LCHAT_CLOUD_AI_RATES: %v", err)
+ }
+ for model, r := range rates {
+ if r.In < 0 || r.Out < 0 || r.CacheWrite < 0 || r.CacheRead < 0 {
+ return nil, fmt.Errorf("negative rate for %q", model)
+ }
+ }
+ return rates, nil
+}
+
+// budgetMonth is the allowance window — a UTC calendar month, matching the
+// ledger's day grain.
+func budgetMonth() string { return time.Now().UTC().Format("2006-01") }
+
+// usageCostEUR prices ledger rows against the rate book. Unknown models and
+// non-token dimensions cost zero — activity counters (charter_runs) are
+// measurement, not money, and a model missing from the rate book is an
+// operator gap worth logging, not a reason to guess a price.
+func usageCostEUR(rows []botUsageRow, rates map[string]aiRates) float64 {
+ var eur float64
+ for _, row := range rows {
+ r, ok := rates[row.Model]
+ if !ok {
+ if row.Model != "" {
+ log.Printf("cloud: budget: no rate for model %q — its tokens price as 0 (add it to LCHAT_CLOUD_AI_RATES)", row.Model)
+ }
+ continue
+ }
+ perM := map[string]float64{
+ "llm_input_tokens": r.In,
+ "llm_output_tokens": r.Out,
+ "llm_cache_write_tokens": r.CacheWrite,
+ "llm_cache_read_tokens": r.CacheRead,
+ }[row.Dimension]
+ eur += float64(row.Quantity) / 1e6 * perM
+ }
+ return eur
+}
+
+// budgetState answers one space's state for a flush response: "ok" or
+// "exhausted". Ordered cheap-to-dear: no config short-circuits before any
+// ledger read, the per-space allowance before the fleet-wide sum.
+func (s *cloudServer) budgetState(spaceID int64) string {
+ if len(s.aiRates) == 0 || s.aiAllowanceEUR <= 0 {
+ return "ok" // measure-only until the operator prices the budget
+ }
+ month := budgetMonth()
+ rows, err := s.db.usageForMonth(spaceID, month)
+ if err != nil {
+ log.Printf("cloud: budget: space %d month read: %v", spaceID, err)
+ return "ok" // a ledger blip must never silence a space's bot
+ }
+ if usageCostEUR(rows, s.aiRates) >= s.aiAllowanceEUR {
+ return "exhausted"
+ }
+ if s.aiPoolEUR > 0 {
+ fleet, err := s.db.fleetUsageForMonth(month)
+ if err != nil {
+ log.Printf("cloud: budget: fleet month read: %v", err)
+ return "ok"
+ }
+ if usageCostEUR(fleet, s.aiRates) >= s.aiPoolEUR {
+ log.Printf("cloud: budget: the fleet AI pool for %s is spent — subsidized AI paused fleet-wide", month)
+ return "exhausted"
+ }
+ }
+ return "ok"
+}
diff --git a/cloud_budget_test.go b/cloud_budget_test.go
new file mode 100644
index 0000000..4c038d0
--- /dev/null
+++ b/cloud_budget_test.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "net/http"
+ "testing"
+ "time"
+)
+
+func testRates() map[string]aiRates {
+ return map[string]aiRates{
+ // Round numbers so the arithmetic below reads at a glance:
+ // €10/M in, €50/M out, €12.50/M cache-write, €1/M cache-read.
+ "claude-sonnet-5": {In: 10, Out: 50, CacheWrite: 12.5, CacheRead: 1},
+ }
+}
+
+func TestParseAIRates(t *testing.T) {
+ rates, err := parseAIRates(`{"claude-sonnet-5":{"in":3,"out":15,"cw":3.75,"cr":0.3}}`)
+ if err != nil || rates["claude-sonnet-5"].Out != 15 {
+ t.Fatalf("parse: %v %+v", err, rates)
+ }
+ if r, err := parseAIRates(""); err != nil || r != nil {
+ t.Fatalf("empty must be off: %v %v", r, err)
+ }
+ if _, err := parseAIRates(`{"m":{"in":-1}}`); err == nil {
+ t.Fatal("negative rates must be refused")
+ }
+ if _, err := parseAIRates(`nonsense`); err == nil {
+ t.Fatal("malformed JSON must be refused")
+ }
+}
+
+func TestUsageCostEUR(t *testing.T) {
+ rows := []botUsageRow{
+ {Model: "claude-sonnet-5", Dimension: "llm_input_tokens", Quantity: 1_000_000}, // €10
+ {Model: "claude-sonnet-5", Dimension: "llm_output_tokens", Quantity: 200_000}, // €10
+ {Model: "claude-sonnet-5", Dimension: "llm_cache_read_tokens", Quantity: 3_000_000}, // €3
+ {Model: "claude-sonnet-5", Dimension: "llm_requests", Quantity: 42}, // request counts are free
+ {Dimension: "charter_runs", Quantity: 7}, // activity, not money
+ {Model: "unpriced-model", Dimension: "llm_output_tokens", Quantity: 1_000_000}, // no rate → 0 + a log line
+ }
+ if got := usageCostEUR(rows, testRates()); got != 23 {
+ t.Fatalf("cost = %v, want 23", got)
+ }
+}
+
+// The state machine end to end through the ingest handler: under allowance is
+// ok, over allowance is exhausted, and a drained fleet pool exhausts a space
+// that is itself under its allowance.
+func TestBudgetStateThroughIngest(t *testing.T) {
+ db := openTestCloudDB(t)
+ a := insertSpaceRow(t, db, "a.11ch.at", 1000)
+ b := insertSpaceRow(t, db, "b.11ch.at", 1000)
+ for id, tok := range map[int64]string{a: "tok-a", b: "tok-b"} {
+ if err := db.setMeterTokenHash(id, hashToken(tok)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ s := &cloudServer{db: db, aiRates: testRates(), aiAllowanceEUR: 5, aiPoolEUR: 12}
+ day := time.Now().UTC().Format("2006-01-02")
+
+ report := func(tok, reportID string, outTokens int64) map[string]any {
+ t.Helper()
+ code, body := ingestUsageReport(t, s, tok, reportID, []botUsageRow{
+ {Day: day, Model: "claude-sonnet-5", Dimension: "llm_output_tokens", Quantity: outTokens}})
+ if code != http.StatusOK {
+ t.Fatalf("ingest: %d %v", code, body)
+ }
+ return body["budget"].(map[string]any)
+ }
+
+ // €2 of output: comfortably inside the €5 allowance.
+ if st := report("tok-a", "aaaaaaaaaaaaaaa1", 40_000); st["state"] != "ok" {
+ t.Fatalf("under allowance: %v", st)
+ }
+ // Another €4 → €6 total: over the per-space allowance.
+ if st := report("tok-a", "aaaaaaaaaaaaaaa2", 80_000); st["state"] != "exhausted" {
+ t.Fatalf("over allowance: %v", st)
+ }
+ // Space B is fresh (€4 < €5 allowance)… but the fleet pool (€12) is now
+ // €6 + €4 = €10; one more €4 pushes past it and B exhausts too.
+ if st := report("tok-b", "bbbbbbbbbbbbbbb1", 80_000); st["state"] != "ok" {
+ t.Fatalf("pool not yet drained: %v", st)
+ }
+ if st := report("tok-b", "bbbbbbbbbbbbbbb2", 80_000); st["state"] != "exhausted" {
+ t.Fatalf("pool drained must exhaust: %v", st)
+ }
+
+ // Unconfigured stays measure-only whatever the ledger says.
+ s2 := &cloudServer{db: db}
+ if st := s2.budgetState(a); st != "ok" {
+ t.Fatalf("unconfigured: %q", st)
+ }
+}
diff --git a/cloud_db.go b/cloud_db.go
index 1f93c36..7eadfc7 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -174,6 +174,29 @@ func (db *cloudDB) pruneUsageReports(cutoff int64) error {
return err
}
+// usageForMonth reads one space's ledger rows for a UTC month ("2006-01") —
+// the budget's allowance window (cloud_budget.go).
+func (db *cloudDB) usageForMonth(spaceID int64, month string) ([]botUsageRow, error) {
+ rows, err := db.sql.Query(
+ `SELECT day, model, dimension, quantity FROM usage WHERE space_id = ? AND day LIKE ?`,
+ spaceID, month+"-%")
+ if err != nil {
+ return nil, err
+ }
+ return scanUsageRows(rows)
+}
+
+// fleetUsageForMonth sums every space's rows for a month — the pool's view.
+func (db *cloudDB) fleetUsageForMonth(month string) ([]botUsageRow, error) {
+ rows, err := db.sql.Query(
+ `SELECT day, model, dimension, SUM(quantity) FROM usage WHERE day LIKE ?
+ GROUP BY day, model, dimension`, month+"-%")
+ if err != nil {
+ return nil, err
+ }
+ return scanUsageRows(rows)
+}
+
// 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(
@@ -181,6 +204,10 @@ func (db *cloudDB) usageFor(spaceID int64) ([]botUsageRow, error) {
if err != nil {
return nil, err
}
+ return scanUsageRows(rows)
+}
+
+func scanUsageRows(rows *sql.Rows) ([]botUsageRow, error) {
defer rows.Close()
var out []botUsageRow
for rows.Next() {
diff --git a/cloud_usage.go b/cloud_usage.go
index 351ee0e..aaf9984 100644
--- a/cloud_usage.go
+++ b/cloud_usage.go
@@ -47,10 +47,10 @@ func (s *cloudServer) usageHandler(w http.ResponseWriter, r *http.Request) {
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"}})
+ // The budget answer rides the flush response back to the sidecar — no
+ // extra channel. Unconfigured, budgetState is a constant "ok" and this is
+ // exactly phase 1's behavior (cloud_budget.go).
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": map[string]any{"state": s.budgetState(spaceID)}})
}
// aiOptInHandler provisions a space's bot sidecar when its admin switches AI
diff --git a/internal/bot/appbuild.go b/internal/bot/appbuild.go
index 50661ac..9a620e2 100644
--- a/internal/bot/appbuild.go
+++ b/internal/bot/appbuild.go
@@ -682,7 +682,7 @@ func (r *Runtime) runAppBuild(tc *ThreadCtx, req appBuildReq, requester, request
// generateApp is the dedicated build call (+ regenerate-on-validation-failure
// loop). Returns the validated source and its event types.
func (r *Runtime) generateApp(req appBuildReq, prevSource string) (string, []string, error) {
- if r.ai == nil || r.appGuide == "" {
+ if r.ai == nil || r.appGuide == "" || r.budgetExhausted() {
return "", nil, fmt.Errorf("app building isn't available right now.")
}
system := []anthBlock{
diff --git a/internal/bot/appbuild_test.go b/internal/bot/appbuild_test.go
index 1bb324a..86ee5c1 100644
--- a/internal/bot/appbuild_test.go
+++ b/internal/bot/appbuild_test.go
@@ -127,11 +127,11 @@ func TestParseAppBuildReq(t *testing.T) {
t.Fatalf("good req rejected: %v", err)
}
bad := []map[string]any{
- {"name": "Habits", "title": "T", "glyph": "🌱", "spec": "s"}, // uppercase name
- {"name": "9habits", "title": "T", "glyph": "🌱", "spec": "s"}, // starts with digit
- {"name": "habits", "title": "", "glyph": "🌱", "spec": "s"}, // empty title
- {"name": "habits", "title": "T", "glyph": "", "spec": "s"}, // empty glyph
- {"name": "habits", "title": "T", "glyph": "🌱", "spec": ""}, // empty spec
+ {"name": "Habits", "title": "T", "glyph": "🌱", "spec": "s"}, // uppercase name
+ {"name": "9habits", "title": "T", "glyph": "🌱", "spec": "s"}, // starts with digit
+ {"name": "habits", "title": "", "glyph": "🌱", "spec": "s"}, // empty title
+ {"name": "habits", "title": "T", "glyph": "", "spec": "s"}, // empty glyph
+ {"name": "habits", "title": "T", "glyph": "🌱", "spec": ""}, // empty spec
{"name": "habits", "title": "T", "glyph": "🌱", "spec": "s", "app_id": "NO"}, // bad app_id
}
for i, in := range bad {
diff --git a/internal/bot/brain.go b/internal/bot/brain.go
index 84478ff..3913f4e 100644
--- a/internal/bot/brain.go
+++ b/internal/bot/brain.go
@@ -459,6 +459,16 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) {
if r.ai == nil {
return Reply{}, false
}
+ if r.budgetExhausted() {
+ // The space's AI budget is spent (phase 3, docs/ai-provisioning.md):
+ // explain once, honestly, then keep to the command floor. Per the
+ // conduct rule (knowledge.md §20) the notice describes where the
+ // admin acts — never a payment link, never a repeated upsell.
+ if r.noteBudgetExhausted() {
+ return Reply{Text: "I've used this space's included AI for now, so I'm sticking to plain commands — try /help for what still works. An admin can bring the AI back from the Admin app."}, true
+ }
+ return Reply{}, false
+ }
// Best-effort "…is typing", re-beaten for the whole generation: a single
// beat's 15s expiry lapses under a long thinking run.
stop := make(chan struct{})
diff --git a/internal/bot/brain_test.go b/internal/bot/brain_test.go
index 383a703..b635ec1 100644
--- a/internal/bot/brain_test.go
+++ b/internal/bot/brain_test.go
@@ -126,9 +126,9 @@ func TestActionsFromToolUseAppCommand(t *testing.T) {
t.Fatalf("chip: %+v", got)
}
bad := actionsFromToolUse([]anthBlock{
- {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "remind me at 4"}}, // no slash
- {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo @here buy milk"}}, // group side effect
- {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo a\nb"}}, // multi-line
+ {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "remind me at 4"}}, // no slash
+ {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo @here buy milk"}}, // group side effect
+ {Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/todo a\nb"}}, // multi-line
{Type: "tool_use", Name: "app_command", Input: map[string]any{"command": "/" + strings.Repeat("x", 300)}}, // too long
})
if len(bad) != 0 {
diff --git a/internal/bot/meter.go b/internal/bot/meter.go
index a6610e7..01752d1 100644
--- a/internal/bot/meter.go
+++ b/internal/bot/meter.go
@@ -188,5 +188,27 @@ func (r *Runtime) flushUsage() {
r.pendingUsage = nil
if resp.Budget.State != "" {
r.budgetState.Store(resp.Budget.State)
+ if resp.Budget.State != "exhausted" {
+ // Back within budget (a new month, a raised allowance): re-arm
+ // the one-time notice for the next exhaustion.
+ r.budgetNotified.Store(false)
+ }
}
}
+
+// budgetExhausted is phase 3's enforcement gate (docs/ai-provisioning.md):
+// when the cloud's last flush response said this space's AI budget is spent,
+// every model path stands down and the deterministic command floor keeps
+// answering — the same degrade shape as ai_enabled off, plus one honest
+// notice. Unmetered, "ok", or never-flushed all read as within budget.
+func (r *Runtime) budgetExhausted() bool {
+ v, _ := r.budgetState.Load().(string)
+ return v == "exhausted"
+}
+
+// noteBudgetExhausted returns true exactly once per exhaustion (re-armed when
+// the budget recovers), so the bot explains itself without repeating. Process
+// memory only: a restart repeats the notice at most once more.
+func (r *Runtime) noteBudgetExhausted() bool {
+ return r.budgetNotified.CompareAndSwap(false, true)
+}
diff --git a/internal/bot/meter_test.go b/internal/bot/meter_test.go
index 515e312..7458eb6 100644
--- a/internal/bot/meter_test.go
+++ b/internal/bot/meter_test.go
@@ -188,3 +188,36 @@ func TestFlushUsageOutboxRetriesVerbatim(t *testing.T) {
t.Fatalf("budget state not stored: %v", r.budgetState.Load())
}
}
+
+// Phase 3's sidecar half: an "exhausted" budget state gates the model paths
+// (one honest notice, then silence) and recovery re-arms the notice.
+func TestBudgetExhaustionGate(t *testing.T) {
+ r := &Runtime{Session: &Session{Name: "Eleven"}}
+ if r.budgetExhausted() {
+ t.Fatal("never-flushed must read as within budget")
+ }
+ r.budgetState.Store("exhausted")
+ if !r.budgetExhausted() {
+ t.Fatal("exhausted state not read")
+ }
+ if !r.noteBudgetExhausted() {
+ t.Fatal("first notice must fire")
+ }
+ if r.noteBudgetExhausted() {
+ t.Fatal("the notice is once-per-exhaustion")
+ }
+ // A task run refuses without burning tokens (ai is set, budget is not).
+ r.ai = &anthropicClient{}
+ if _, _, err := r.execTaskOnce("p", &TaskSpec{Prompt: "x"}, false); err == nil {
+ t.Fatal("exhausted budget must refuse task runs")
+ }
+ // Recovery (simulated flush storing a non-exhausted state) re-arms.
+ r.budgetState.Store("ok")
+ r.budgetNotified.Store(false)
+ if r.budgetExhausted() {
+ t.Fatal("ok state must clear the gate")
+ }
+ if !r.noteBudgetExhausted() {
+ t.Fatal("notice must re-arm after recovery")
+ }
+}
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go
index 7030cfc..dffb6cc 100644
--- a/internal/bot/runtime.go
+++ b/internal/bot/runtime.go
@@ -59,6 +59,8 @@ type Runtime struct {
// failed flushes so the ledger can dedupe a replay whose first ack was
// lost. Touched only on the meterLoop goroutine.
pendingUsage *usageReport
+ // budgetNotified arms the once-per-exhaustion notice (meter.go).
+ budgetNotified atomic.Bool
// 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.
diff --git a/internal/bot/task.go b/internal/bot/task.go
index a7707be..8feaa93 100644
--- a/internal/bot/task.go
+++ b/internal/bot/task.go
@@ -174,6 +174,11 @@ func (r *Runtime) execTaskOnce(persona string, t *TaskSpec, think bool) (res tas
// misconfiguration the owner should see once per firing, in the log.
return res, "", fmt.Errorf("no model available (ANTHROPIC_API_KEY unset)")
}
+ if r.budgetExhausted() {
+ // Budget-spent runs ride the charter failure machinery (backoff, one
+ // notice per outage) instead of burning tokens the space doesn't have.
+ return res, "", fmt.Errorf("the space's included AI is used up for now — runs resume when the budget does")
+ }
model := firstOf(t.Model, r.model, defaultModel)
maxCalls := t.MaxToolCalls
if maxCalls == 0 {