"""The budget meter (method-spec §8) — never an unbounded loop, anywhere. Token accounting comes from the PROVIDER-REPORTED usage after each model call — never a word-count or character proxy; on counting paths a response missing usage fails CLOSED (``UsageAccountingError``), not silently uncounted. Crossing a cap raises ``BudgetExceeded``, a STRUCTURED stop event carrying the breached kind, the limit, and the observed value — never a silent hang. The token/round caps come from the fail-fast startup ``TerminationContract`` (§10), which already refuses non-positive values. On TOP of the post-charge token/round meter sits an optional PRE-call USD belt (C3.5): a run-total ``max_cost_usd`` cap the loop checks BEFORE every model call against the SDK client's accumulated ``total_cost_usd``. Once the run-total cost has crossed the cap the next call is refused with the same structured stop event (``kind="cost_usd"``) — belt-and-braces above the SDK's per-call USD cap, so no future live run can loop past its run budget. """ from __future__ import annotations from typing import Literal from portfolio_optimiser_claude.contracts import TerminationContract BudgetKind = Literal["tokens", "rounds", "cost_usd"] class UsageAccountingError(Exception): """A response missing provider-reported usage on a counting path (§8).""" class BudgetExceeded(Exception): """The structured stop event: breached kind + limit + observed value (§8).""" def __init__(self, kind: BudgetKind, limit: float, observed: float) -> None: super().__init__(f"budget exceeded: {kind} observed {observed} > limit {limit}") self.kind: BudgetKind = kind self.limit = limit self.observed = observed class BudgetMeter: """Run-scoped usage meter over the startup termination contract (§8). ``max_cost_usd`` is an OPTIONAL run-total USD cap for the pre-call belt (C3.5); left unset the belt is a no-op and only the token/round caps apply. When set it must be positive (§10 fail-fast discipline). """ def __init__( self, termination: TerminationContract, *, max_cost_usd: float | None = None ) -> None: if max_cost_usd is not None and max_cost_usd <= 0: raise ValueError(f"max_cost_usd must be positive when set, got {max_cost_usd}") self._termination = termination self._max_cost_usd = max_cost_usd self.tokens_used = 0 self.rounds_used = 0 def charge_tokens(self, usage_tokens: int | None) -> None: """Charge provider-reported usage; a missing usage fails closed (§8).""" if usage_tokens is None: raise UsageAccountingError( "response carries no usage — token accounting must fail closed (§8)" ) self.tokens_used += usage_tokens if self.tokens_used > self._termination.max_tokens: raise BudgetExceeded("tokens", self._termination.max_tokens, self.tokens_used) def charge_round(self) -> None: """Charge one round tick (debate rounds and between-attempt ticks, §8).""" self.rounds_used += 1 if self.rounds_used > self._termination.max_rounds: raise BudgetExceeded("rounds", self._termination.max_rounds, self.rounds_used) def guard_before_call(self, spent_usd: float) -> None: """Pre-call USD belt (C3.5): refuse the NEXT model call once the accumulated run-total cost has crossed the cap. This is the ONLY cap read from OUTSIDE the meter — the SDK client accumulates ``total_cost_usd`` from each ``ResultMessage`` and the loop passes it here BEFORE every ``client.complete``. No cap configured (or a scripted client reporting no spend) makes this a no-op, so the offline suite is untouched. Reaching the cap exactly does not stop (mirrors the token cap); crossing it raises the structured stop event (§8).""" if self._max_cost_usd is None: return if spent_usd > self._max_cost_usd: raise BudgetExceeded("cost_usd", self._max_cost_usd, spent_usd)