m12-pr08 P0-4: per-org daily cost cap + cascade breaker#26
Open
BalaShankar9 wants to merge 1 commit into
Open
Conversation
Wires the spend telemetry from m12-pr07 (PR #25) into a hard breaker that refuses LLM calls before any provider tier is touched once an org is past its configured daily $ cap. What ships ---------- - New `ai_engine/cost_breaker.py`: * `OrgDailyCostCapExceeded` exception carrying tenant_id, spent_cents, cap_cents. * `current_tenant_id` ContextVar with `set_tenant_id` / `reset_tenant_id` / `get_tenant_id` / async `tenant_scope` helpers — single canonical place to bind tenant identity for downstream LLM calls. * `cap_cents_for(tenant_id)` reads `ORG_DAILY_COST_CAP_USD` (global) and `ORG_DAILY_COST_CAP_OVERRIDES` (per-tenant JSON map). 0 / unset / parse error all mean "cap disabled" (fail-open). * `enforce_org_daily_cost_cap()` — async; queries `CostAttributionService.get_org_cost_today_cents` (FRESH base-table read, not the 60s MV) and raises `OrgDailyCostCapExceeded` when spent_cents >= cap_cents. Emits `cost.cap.tripped` event via the bound emitter (best-effort). Fail-open on cost-service errors so a Supabase outage cannot black-hole all LLM traffic. - `ai_engine/client.py`: * New `_enforce_cost_cap()` helper. * Called from `complete()`, `complete_json()`, `stream_completion()`, and `chat()` immediately after `_check_budget()` and before any cascade work. One chokepoint per public LLM entrypoint covers all 9 production call sites. * `_record_invocation` now auto-pulls tenant_id from the contextvar when callers don't pass one, so cost attribution writes are tied to the same tenant scope the breaker enforces. - Tests: `backend/tests/unit/test_cost_breaker.py` — 20 tests: ContextVar lifecycle, env parsing (global + overrides + 0 disables), unset = no DB call, under cap = pass, over cap = raise with right attrs, exact-cap boundary raises, contextvar tenant resolves, fail-open on DB error, event emitted on trip, `emit_event=False` suppresses, exception message format. 30/30 green with test_cost_attribution.py. - Blueprint: P0-4 flipped TODO -> SHIPPED. Why this shape -------------- - Breaker fires BEFORE the cascade loop, not inside it -- a tripped cap costs zero LLM tokens. - Tenant flows via ContextVar so call sites don't need to thread an org_id parameter through every chain / agent / tool layer. Caller sets `tenant_scope("org-X")` once at request entry; the breaker AND the recorder both pick it up. - Fail-open everywhere: bad env, JSON parse error, missing override, cost service down, event-emitter exception -- nothing in the cap machinery blocks a legitimate call when the cap subsystem itself is unhealthy. - Cap source is env-only (no new feature flag) because the value is per-deployment, not per-feature-flip. Per-tenant overrides live in a JSON env to keep the blast radius of a typo small. Stack ----- Branch: m12-pr08-cost-cap (off m12-pr07-cost-attribution / PR #25) Files: 3 changed, 1 new test file - ai_engine/cost_breaker.py (new) - ai_engine/client.py (5 hooks) - backend/tests/unit/test_cost_breaker.py (new) - docs/architecture/WORLD_CLASS_ARCHITECTURE_BLUEPRINT.md (flip)
There was a problem hiding this comment.
Pull request overview
Implements blueprint P0-4 by introducing a per-tenant daily LLM spend cap (“cascade breaker”) enforced at the entry of AIClient public LLM methods, driven by tenant identity propagated via a ContextVar and backed by CostAttributionService’s “today” spend read.
Changes:
- Added
ai_engine.cost_breakerwith tenant ContextVar helpers, env-driven cap parsing (global + per-tenant overrides), and async enforcement that raisesOrgDailyCostCapExceeded. - Wired enforcement into
AICliententrypoints and made invocation recording defaulttenant_idfrom the ContextVar when not explicitly provided. - Added unit tests for breaker behavior and updated the architecture blueprint status for P0-4.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| docs/architecture/WORLD_CLASS_ARCHITECTURE_BLUEPRINT.md | Marks P0-4 as shipped and documents the cap/breaker behavior. |
| backend/tests/unit/test_cost_breaker.py | New unit tests covering ContextVar lifecycle, env parsing, enforcement, and event emission. |
| ai_engine/cost_breaker.py | New module implementing cap parsing + enforcement and the OrgDailyCostCapExceeded exception. |
| ai_engine/client.py | Enforces the cap on LLM entrypoints and auto-attaches tenant_id to invocation records from the ContextVar. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| | P0-2 | Eliminate in-process generation fallback; fail-fast 503 | W2 | platform | **SHIPPED** — m7-pr27b | | ||
| | P0-3 | Queue ACK on success only; DLQ + idempotent handlers | W3 | platform | **SHIPPED** — m7-pr27c | | ||
| | P0-4 | Per-org daily $ cap + cascade-failure breaker | W8 | platform | TODO | | ||
| | P0-4 | Per-org daily $ cap + cascade-failure breaker | W8 | platform | SHIPPED (m12-pr08) — `ai_engine.cost_breaker` reads `ORG_DAILY_COST_CAP_USD` (+ per-tenant overrides), pulls today's spend via `CostAttributionService.get_org_cost_today_cents`, raises `OrgDailyCostCapExceeded` at the entry of every cascade method, emits `cost.cap.tripped` event, fail-open on telemetry errors | |
Comment on lines
+75
to
+86
| def _parse_global_cap_cents() -> Optional[int]: | ||
| raw = (os.getenv("ORG_DAILY_COST_CAP_USD", "") or "").strip() | ||
| if not raw: | ||
| return None | ||
| try: | ||
| usd = float(raw) | ||
| except ValueError: | ||
| logger.warning("invalid_org_daily_cost_cap_usd: raw=%r", raw) | ||
| return None | ||
| if usd <= 0: | ||
| return None | ||
| return int(round(usd * 100)) |
Comment on lines
+191
to
+199
| try: | ||
| from backend.app.services.cost_attribution import ( | ||
| get_cost_attribution_service, | ||
| ) | ||
| spent = await get_cost_attribution_service().get_org_cost_today_cents(tid) | ||
| except Exception as exc: | ||
| # Fail-open: telemetry outage must not break the request path. | ||
| logger.warning("cost_cap_check_skipped_due_to_error: tenant=%s err=%s", | ||
| tid, str(exc)[:200]) |
Comment on lines
+1096
to
+1109
| async def _enforce_cost_cap(self) -> None: | ||
| """P0-4 / m12-pr08: refuse the call when the org is over its daily $ cap. | ||
|
|
||
| Raises :class:`ai_engine.cost_breaker.OrgDailyCostCapExceeded` when | ||
| the tenant bound to the current context has spent past its | ||
| configured cap. No-op when no tenant is bound or no cap is set. | ||
| Fails open on cost-service errors. | ||
| """ | ||
| try: | ||
| from ai_engine.cost_breaker import enforce_org_daily_cost_cap | ||
| except Exception: # pragma: no cover — defensive import guard | ||
| return | ||
| await enforce_org_daily_cost_cap() | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
P0-4 from the World Class Architecture Blueprint. Wires the spend telemetry shipped in #25 into a hard cascade breaker that refuses LLM calls before any provider tier is touched once an org has spent past its configured daily $ cap.
What ships
ai_engine/cost_breaker.py— new module:OrgDailyCostCapExceededexception (tenant_id,spent_cents,cap_cents).set_tenant_id/reset_tenant_id/get_tenant_id/ asynctenant_scopeContextVar helpers — single canonical place to bind tenant identity for downstream LLM calls.cap_cents_for(tenant_id)readsORG_DAILY_COST_CAP_USD(global) andORG_DAILY_COST_CAP_OVERRIDES(per-tenant JSON map). 0 / unset / parse error all mean "cap disabled" (fail-open).enforce_org_daily_cost_cap()— async; queriesCostAttributionService.get_org_cost_today_cents(FRESH base-table read, not the 60s MV) and raises whenspent_cents >= cap_cents. Emitscost.cap.trippedon the bound emitter (best-effort). Fail-open on cost-service errors.ai_engine/client.py— 5 hooks:_enforce_cost_cap()helper.complete(),complete_json(),stream_completion(), andchat()immediately after_check_budget()and before any cascade work. One chokepoint per public LLM entrypoint covers all 9 production call sites._record_invocationnow auto-pullstenant_idfrom the ContextVar when callers don't pass one — same scope drives both attribution writes and cap enforcement.Tests —
backend/tests/unit/test_cost_breaker.py, 20 tests:cost.cap.trippedemitted on trip,emit_event=Falsesuppresses.Result: 30/30 green with
test_cost_attribution.py(no regression on #25).Blueprint: P0-4 flipped
TODO→SHIPPED.Why this shape
org_idparameter through every chain / agent / tool layer. Caller setstenant_scope("org-X")once at request entry; the breaker AND the recorder both pick it up.Stack
Files: 4 changed, +487 / -1.
Deployment notes
Cap is OFF until an operator sets
ORG_DAILY_COST_CAP_USD(e.g.50for $50/day). To enable per-tenant:(
0= disabled for that tenant; positive = override the global cap.)For tenant scoping to work, request entrypoints (HTTP handlers, worker job pickup, SSE start) must wrap their LLM-touching code with
async with tenant_scope(org_id): .... Wiring the entrypoints is intentionally out-of-scope — separate PR per entrypoint family.