Files
gbrain/docs/operations/spend-controls.md
T
0bbaed2e48 v0.42.67.0 feat(migrate): provider-agnostic embedding migration — the path off ZeroEntropy (#3390, fixes #3391) (#3459)
* feat(migrate): provider-agnostic embedding migration service — the path off ZeroEntropy (#3390)

- gbrain migrate embeddings --to <provider:model> (alias: retrieval-upgrade):
  plan + cost preflight, consent gate (--yes / TTY confirm / non-TTY exit 2),
  live probe against the target provider before any mutation, env-override
  gate, schema dimension transition via the shared runSchemaTransition,
  dual-plane config write, NULL-signature-inclusive invalidation, query-cache
  purge, resumable re-embed through the standard embed pipeline (single-flight
  locks, backoff, pacing, stderr progress). Killed runs resume by re-running
  the same command; the NULL-embedding column is the checkpoint.
- #3391 root-cause fix (both engines): countStaleChunks / sumStaleChunkChars /
  invalidateStaleSignatureEmbeddings accept includeNullSignature to lift the
  v108 grandfather clause; embed --stale warns loudly when a model swap
  leaves NULL-signature pages in the old embedding space, and
  --include-null-signature re-embeds them. Default sweep behavior unchanged.
- knobs_hash v=12 → v=13 (prov=default legacy callers must not be served
  pre-migration cache rows).
- migrate_embeddings op: scope admin, localOnly, hidden cliHints, hard
  remote refusal, needs_confirmation without yes=true.
- One-shot post-upgrade ZE-sunset banner (ze_sunset_notice_shown) for brains
  resolving to a zeroentropyai:* embedding model or reranker.
- doctor's dimension-mismatch repair hint now names the real command.
- Docs: docs/guides/embedding-migration.md, KEY_FILES entries, spend-controls
  gate row. Tests: PGLite unit + full-lifecycle flow (interrupted-run resume),
  real-Postgres e2e (pgvector DDL path + #3391 predicate parity).

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

* fix(test): satisfy check:test-isolation + bump the remaining knobs_hash pins

- test/migrate-embeddings-flow.test.ts → .serial.test.ts: the file holds a
  temp GBRAIN_HOME + an installed fake embed transport for its whole
  lifecycle (beforeAll → afterAll), which withEnv() can't wrap. This also
  fixes the CI shard-pollution failure in
  test/ai/recipes-existing-regression.test.ts (that file passes solo on both
  master and this branch; the flow test's configureGateway + provider-key
  deletion was leaking into it inside the same shard process).
- test/embedding-migration.test.ts: env-override case now uses withEnv().
- Bump the three remaining KNOBS_HASH_VERSION pins to 13
  (cross-modal-phase1, search-alias-resolved-boost, search/knobs-hash-reranker).
- Docs + llms bundles follow the test rename.

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

* chore(test): wire the new Postgres e2e into the smart e2e selector map

Changes to embed.ts / embedding-migration.ts / retrieval-upgrade-planner.ts /
postgres-engine.ts now trigger test/e2e/migrate-embeddings-postgres.test.ts —
the #3391 stale predicates and runSchemaTransition's DDL path behave
differently on real pgvector than on PGLite, so the smart selector has to know.

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

* feat(migrate): consult spend.posture in the embedding-migration consent gate

The brief asked the gate to honor spend.posture; it previously didn't read it
at all. Now it does — but deliberately does NOT bypass on tokenmax: posture
waives the spend CEILING, and this gate also guards a destructive schema
rebuild (existing vectors dropped, retrieval degraded until the re-embed
finishes). Under tokenmax the dollar figure is marked informational on stderr
and the confirmation is still asked; --yes stays the single scripted bypass.

Pinned by a new case in the flow test so a later refactor can't quietly turn
posture into a bypass. Guide + spend-controls table updated to match.

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

* wip: blocker fixes

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:48:37 -07:00

5.9 KiB

Spend controls

GBrain's embedding-spend gates in one place: every gate, its config key, default, whether it blocks or just informs, how to widen or disable it, and how the spend.posture switch governs all of them.

The orienting idea: GBrain itself is rounding error; the spend that matters is downstream embedding. These gates exist so a routine sync or enrich can't run up an unexpected embedding bill, while never wedging an unattended cron.

spend.posture — one switch for "cost is not my constraint"

gbrain config set spend.posture tokenmax   # all cost gates become informational
gbrain config set spend.posture gated      # default — gates enforce
Value Effect
gated (default) Every cost gate enforces its limit as documented below.
tokenmax Every cost gate prints its estimate and proceeds — informational only. Spend is still recorded to the ledger; posture removes the ceiling, not the accounting.

spend.posture is deliberately separate from search.mode=tokenmax (which governs retrieval payload size, not embedding spend). When a gate fires and search.mode=tokenmax but spend.posture is unset, the gate prints a one-line hint pointing at this switch.

Precedence: an explicit per-call cap (--max-usd N, --max-cost N) always wins over posture. tokenmax only governs the default/absent case — it never overrides a number you typed on the command line.

Off switches (off / unlimited / none)

The USD-limit knobs accept off, unlimited, or none (case-insensitive) to mean "no limit" — no more setting sentinel values like 100000.

  • 0 is not "off". On sync.cost_gate_min_usd, 0 means "block on any nonzero spend" (a real choice). On the backfill caps, 0 falls back to the default.
  • Internally "no limit" is the string unlimited in any printed/JSON output and "no cap" inside the budget tracker — never a raw Infinity (which would serialize to null in ledger rows).

The gates

Gate Config key Default Blocks? Off switch tokenmax
Sync inline-embed cost gate sync.cost_gate_min_usd 0.50 TTY prompt / non-TTY auto-defer off (or 0 = block-on-any) informational
Backfill 24h per-source spend cap embed.backfill_max_usd_per_source_24h 25 refuses submission off (0 → default) bypassed (still ledgered)
Backfill per-job budget embed.backfill_max_usd 10 caps the job's tracker off (0 → default) uncapped (still ledgered)
Backfill cooldown embed.backfill_cooldown_min 10 skips re-submission inside window — (latency knob, not spend) not bypassed
reindex-code cost gate — (preview before re-embed) TTY prompt / non-TTY refuse + exit 2 --max-cost off informational
migrate embeddings consent gate — (plan + estimate before provider migration) TTY y/N prompt / non-TTY refuse + exit 2 --yes estimate marked informational, but still prompts (guards a destructive schema rebuild, not just spend)
enrich / onboard --auto --max-usd (per-call) refuse without a cap (non-TTY) --max-usd off runs uncapped (still ledgered)

Sync inline-embed cost gate

Fires only when sync embeds inline (federated_v2 off, or --serial without --no-embed). Under federated_v2 + parallel, embedding is deferred to capped backfill jobs and the gate is informational. The estimate prices the delta — the files this sync will actually import (fetched-first, so it sees commits the run is about to pull) — not the whole tree. A busy brain with a dirty working tree but caught-up commits estimates $0, because an attached-HEAD sync imports only the committed diff.

Behavior above the floor:

  • TTY: prompts [y/N].
  • Non-interactive (cron/agent): auto-defers embeds to capped backfill jobs and exits 0 — it never wedges the pipeline. The backlog drains via the jobs worker or gbrain embed --stale. Pass --yes to embed inline instead.

Output format splits on the explicit --json flag: --json emits a structured envelope; otherwise human text. Every gate message carries paste-ready knobs.

--full re-embeds the stale backlog inline (full sync sweeps it), so a --full estimate is delta + stale backlog, labeled as such.

Estimate labels

  • ~N tokens (delta: changed files since last sync) — the precise estimate.
  • <=N tokens (full-tree ceiling for K source(s): <reasons> …) — a conservative over-count used only when a precise delta can't be computed: a first sync, a chunker version drift (forces a full re-chunk), or git being unavailable. Unchanged files still skip via content_hash at execution, so the ceiling over-states real spend.

Notes & limits

  • Pre-pull window: the gate fetches before estimating, so it prices what the run will pull. If a fetch fails (offline), it estimates against local HEAD and labels the result; the bounded residual is priced on the next run.
  • Single-source gbrain sync carries the same gate as sync --all (it previously embedded inline with no preview).
  • Recovery under parallel: --skip-failed / --retry-failed work under parallel sync (the failure ledger is per-source and lock-serialized) — you no longer have to drop to --serial, which is what used to arm the inline gate.

Escape hatches at a glance

# Never gate this brain on cost:
gbrain config set spend.posture tokenmax

# Widen the sync inline floor to $5:
gbrain config set sync.cost_gate_min_usd 5

# Disable the sync inline floor entirely:
gbrain config set sync.cost_gate_min_usd off

# Lift the backfill 24h spend cap:
gbrain config set embed.backfill_max_usd_per_source_24h off

# Run enrich uncapped non-interactively:
gbrain enrich --max-usd off        # or: gbrain config set spend.posture tokenmax