Files
cleveragents-core/robot/helper_cost_budgets.py
freemo 876217d0ca
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 24s
CI / typecheck (pull_request) Successful in 37s
CI / security (pull_request) Successful in 44s
CI / unit_tests (pull_request) Successful in 2m40s
CI / integration_tests (pull_request) Successful in 3m19s
CI / docker (pull_request) Successful in 42s
CI / coverage (pull_request) Successful in 5m10s
CI / lint (push) Successful in 12s
CI / build (push) Successful in 16s
CI / quality (push) Successful in 21s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 37s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Failing after 2m36s
CI / docker (push) Has been skipped
CI / integration_tests (push) Successful in 3m16s
CI / coverage (push) Successful in 5m3s
CI / benchmark-publish (push) Successful in 17m54s
CI / benchmark-regression (pull_request) Successful in 31m55s
feat(guardrails): implement Per-Session and Per-Org Cost Budgets
Implements Forgejo issue #584: three-tier budget hierarchy
(per-plan -> per-session -> per-org) with tightest limit winning.

Domain models:
- BudgetLevel enum (PLAN, SESSION, ORG)
- BudgetCheckResult (frozen Pydantic model with exceeded_level, warning)
- SessionCostBudget (tracks per-session accumulated cost)
- OrgCostAccumulator (tracks per-org accumulated cost)
- ThreadSafeOrgCostAccumulator (thread-safe wrapper with snapshot)

Application services:
- CostBudgetService: manages budget state, enforces hierarchy,
  emits BUDGET_WARNING (once per session) and BUDGET_EXCEEDED events
- AutonomyGuardrailService: extended with associate_plan_with_session,
  check_budget_hierarchy, record_plan_cost_to_session methods

Configuration:
- Settings: session_max_cost_usd, org_max_cost_usd, budget_warning_threshold

Integration:
- Session model: cost_budget field, as_cli_dict includes budget data
- DI container: CostBudgetService registered as Singleton
- CLI session show: cost budget panel display

Tests:
- 54 Behave scenarios (features/cost_budgets.feature)
- 11 Robot Framework integration tests (robot/cost_budgets.robot)
- ASV benchmarks (benchmarks/bench_budget_check.py)

All nox stages pass: lint, typecheck, unit_tests, coverage_report (98%).

CLOSES #584
2026-03-10 14:18:04 -04:00

179 lines
6.0 KiB
Python

"""Helper script for Robot Framework cost budget tests."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application.services.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
from cleveragents.application.services.cost_budget_service import CostBudgetService
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.cost_budget import (
BudgetLevel,
OrgCostAccumulator,
SessionCostBudget,
ThreadSafeOrgCostAccumulator,
)
from cleveragents.domain.models.core.session import Session
def _test_session_record() -> None:
"""Session budget tracks cumulative cost."""
b = SessionCostBudget(max_cost_usd=100.0)
b.record_cost(25.0)
assert b.total_cost == 25.0
assert abs((b.utilization() or 0) - 0.25) < 0.001
assert abs((b.remaining() or 0) - 75.0) < 0.001
print("session-record-ok")
def _test_session_exceeded() -> None:
"""Session budget detects exceeded."""
b = SessionCostBudget(max_cost_usd=10.0)
b.record_cost(15.0)
assert b.is_exceeded()
print("session-exceeded-ok")
def _test_org_record() -> None:
"""Org accumulator tracks cost."""
acc = OrgCostAccumulator(org_id="org-1", max_cost_usd=500.0)
acc.record_cost(100.0)
assert abs(acc.total_cost - 100.0) < 0.001
assert abs((acc.utilization() or 0) - 0.2) < 0.001
assert abs((acc.remaining() or 0) - 400.0) < 0.001
print("org-record-ok")
def _test_org_exceeded() -> None:
"""Org accumulator detects exceeded."""
acc = OrgCostAccumulator(org_id="org-1", max_cost_usd=50.0)
acc.record_cost(60.0)
assert acc.is_exceeded()
print("org-exceeded-ok")
def _test_thread_safe() -> None:
"""Thread-safe wrapper delegates correctly."""
inner = OrgCostAccumulator(org_id="safe-org", max_cost_usd=200.0)
ts = ThreadSafeOrgCostAccumulator(inner)
ts.record_cost(50.0)
assert abs(ts.total_cost - 50.0) < 0.001
assert abs((ts.utilization() or 0) - 0.25) < 0.001
snap = ts.snapshot()
assert snap.org_id == "safe-org"
assert abs(snap.total_cost - 50.0) < 0.001
print("thread-safe-ok")
def _test_hierarchy_allow() -> None:
"""Budget hierarchy allows under all caps."""
svc = CostBudgetService()
svc.configure_session_budget("s1", max_cost_usd=100.0)
svc.configure_org_budget("o1", max_cost_usd=500.0)
svc.configure_session_budget("s1", max_cost_usd=100.0, org_id="o1")
result = svc.check_budget_hierarchy("s1", plan_cost=10.0)
assert result.allowed is True
print("hierarchy-allow-ok")
def _test_hierarchy_session_block() -> None:
"""Budget hierarchy blocks at session level."""
svc = CostBudgetService()
svc.configure_session_budget("s2", max_cost_usd=10.0)
svc.record_plan_cost("s2", 8.0)
result = svc.check_budget_hierarchy("s2", plan_cost=5.0)
assert result.allowed is False
assert result.exceeded_level == BudgetLevel.SESSION
print("hierarchy-session-block-ok")
def _test_hierarchy_org_block() -> None:
"""Budget hierarchy blocks at org level."""
svc = CostBudgetService()
svc.configure_session_budget("s3", max_cost_usd=100.0)
svc.configure_org_budget("o2", max_cost_usd=10.0)
svc.configure_session_budget("s3", max_cost_usd=100.0, org_id="o2")
svc.record_plan_cost("s3", 8.0)
result = svc.check_budget_hierarchy("s3", plan_cost=5.0)
assert result.allowed is False
assert result.exceeded_level == BudgetLevel.ORG
print("hierarchy-org-block-ok")
def _test_hierarchy_plan_block() -> None:
"""Budget hierarchy blocks at plan level."""
svc = CostBudgetService()
svc.configure_session_budget("s4", max_cost_usd=100.0)
result = svc.check_budget_hierarchy(
"s4", plan_cost=5.0, plan_budget=3.0, plan_spent=0.0
)
assert result.allowed is False
assert result.exceeded_level == BudgetLevel.PLAN
print("hierarchy-plan-block-ok")
def _test_guardrail_hierarchy() -> None:
"""Guardrail service integrates budget hierarchy."""
cbs = CostBudgetService()
cbs.configure_session_budget("gs1", max_cost_usd=10.0)
gs = AutonomyGuardrailService(cost_budget_service=cbs)
gs.associate_plan_with_session("gp1", "gs1")
gs.record_plan_cost_to_session("gp1", 8.0)
result = gs.check_budget_hierarchy("gp1", 5.0)
assert result.allowed is False
assert result.exceeded_level == BudgetLevel.SESSION
print("guardrail-hierarchy-ok")
def _test_settings_defaults() -> None:
"""Settings have correct budget defaults."""
s = Settings(_env_file=None) # type: ignore[call-arg]
assert s.session_max_cost_usd is None
assert s.org_max_cost_usd is None
assert abs(s.budget_warning_threshold - 0.8) < 0.001
print("settings-defaults-ok")
def _test_session_model() -> None:
"""Session model includes cost_budget field."""
from ulid import ULID
sess = Session(
session_id=str(ULID()),
cost_budget=SessionCostBudget(max_cost_usd=50.0),
)
assert sess.cost_budget.max_cost_usd == 50.0
sess.cost_budget.record_cost(10.0)
cli = sess.as_cli_dict()
assert "cost_budget" in cli
assert cli["cost_budget"]["total_cost"] == 10.0
print("session-model-ok")
_TESTS = {
"session-record": _test_session_record,
"session-exceeded": _test_session_exceeded,
"org-record": _test_org_record,
"org-exceeded": _test_org_exceeded,
"thread-safe": _test_thread_safe,
"hierarchy-allow": _test_hierarchy_allow,
"hierarchy-session-block": _test_hierarchy_session_block,
"hierarchy-org-block": _test_hierarchy_org_block,
"hierarchy-plan-block": _test_hierarchy_plan_block,
"guardrail-hierarchy": _test_guardrail_hierarchy,
"settings-defaults": _test_settings_defaults,
"session-model": _test_session_model,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _TESTS:
print(f"Usage: {sys.argv[0]} <{'|'.join(sorted(_TESTS))}>", file=sys.stderr)
sys.exit(1)
_TESTS[sys.argv[1]]()