109 lines
3.7 KiB
Python
109 lines
3.7 KiB
Python
|
|
"""Tests for budget enforcement in ReportUsageHooks."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
|
||
|
|
|
||
|
|
|
||
|
|
def _make_hooks(max_budget: float | None) -> ReportUsageHooks:
|
||
|
|
return ReportUsageHooks(model="test-model", max_budget_usd=max_budget)
|
||
|
|
|
||
|
|
|
||
|
|
def _make_report_state(cost: float) -> MagicMock:
|
||
|
|
state = MagicMock()
|
||
|
|
state.get_total_llm_cost.return_value = cost
|
||
|
|
state.record_sdk_usage = MagicMock()
|
||
|
|
return state
|
||
|
|
|
||
|
|
|
||
|
|
def _make_context(agent_id: str = "test-agent") -> MagicMock:
|
||
|
|
ctx: MagicMock = MagicMock()
|
||
|
|
ctx.context = {"agent_id": agent_id}
|
||
|
|
return ctx
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_no_budget_never_raises() -> None:
|
||
|
|
hooks = _make_hooks(None)
|
||
|
|
state = _make_report_state(9999.0)
|
||
|
|
with patch("strix.core.hooks.get_global_report_state", return_value=state):
|
||
|
|
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_under_budget_does_not_raise() -> None:
|
||
|
|
hooks = _make_hooks(10.0)
|
||
|
|
state = _make_report_state(9.99)
|
||
|
|
with patch("strix.core.hooks.get_global_report_state", return_value=state):
|
||
|
|
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_at_budget_raises() -> None:
|
||
|
|
hooks = _make_hooks(10.0)
|
||
|
|
state = _make_report_state(10.0)
|
||
|
|
with (
|
||
|
|
patch("strix.core.hooks.get_global_report_state", return_value=state),
|
||
|
|
pytest.raises(BudgetExceededError),
|
||
|
|
):
|
||
|
|
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_over_budget_raises() -> None:
|
||
|
|
hooks = _make_hooks(10.0)
|
||
|
|
state = _make_report_state(10.01)
|
||
|
|
with (
|
||
|
|
patch("strix.core.hooks.get_global_report_state", return_value=state),
|
||
|
|
pytest.raises(BudgetExceededError),
|
||
|
|
):
|
||
|
|
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_budget_check_uses_live_cost_accessor() -> None:
|
||
|
|
# The check must read the live ledger, not the persisted run-record snapshot,
|
||
|
|
# so it stays accurate even when a save fails after a usage record.
|
||
|
|
hooks = _make_hooks(5.0)
|
||
|
|
state = _make_report_state(6.0)
|
||
|
|
with (
|
||
|
|
patch("strix.core.hooks.get_global_report_state", return_value=state),
|
||
|
|
pytest.raises(BudgetExceededError),
|
||
|
|
):
|
||
|
|
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
|
||
|
|
state.get_total_llm_cost.assert_called_once()
|
||
|
|
state.get_total_llm_usage.assert_not_called()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_error_message_includes_amounts() -> None:
|
||
|
|
hooks = _make_hooks(5.0)
|
||
|
|
state = _make_report_state(7.1234)
|
||
|
|
with patch("strix.core.hooks.get_global_report_state", return_value=state):
|
||
|
|
with pytest.raises(BudgetExceededError, match=r"\$5\.00") as exc_info:
|
||
|
|
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
|
||
|
|
assert "7.1234" in str(exc_info.value)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_no_raise_when_report_state_none() -> None:
|
||
|
|
hooks = _make_hooks(1.0)
|
||
|
|
with patch("strix.core.hooks.get_global_report_state", return_value=None):
|
||
|
|
# Should return early without raising, even with budget set
|
||
|
|
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("bad_budget", [0.0, -0.01, -5.0])
|
||
|
|
def test_non_positive_budget_rejected(bad_budget: float) -> None:
|
||
|
|
with pytest.raises(ValueError, match="greater than 0"):
|
||
|
|
ReportUsageHooks(model="test-model", max_budget_usd=bad_budget)
|
||
|
|
|
||
|
|
|
||
|
|
def test_budget_exceeded_error_is_runtime_error() -> None:
|
||
|
|
err = BudgetExceededError("test")
|
||
|
|
assert isinstance(err, RuntimeError)
|