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
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
179 lines
6.0 KiB
Python
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]]()
|