Add a pre-call USD belt on top of the post-charge token/round meter (§8), so no future live run can loop past its run budget. Belt-and-braces above the SDK's per-call max_budget_usd cap. - budget.py: optional run-total `max_cost_usd` on BudgetMeter (fail-fast on non-positive, §10) + `guard_before_call(spent_usd)` raising the same structured stop event (BudgetKind widened with "cost_usd"; limit/observed → float). Reaching the cap exactly does not stop; crossing it does (mirrors the token cap). - loop.py: `_guarded_complete` helper reads the client's accumulated total_cost_usd (0.0 for scripted clients) and guards BEFORE every client.complete; all three call sites routed through it — one detach point. - sdk_client.py: total_cost_usd already exposed/accumulated — untouched. - tests/test_budget.py: meter-level cap tests + load-bearing loop-wiring test (counting client; detach the guard → unguarded loop runs to the round cap → kind "rounds" not "cost_usd" → red). 457→462 green, golden byte-exact, full gate clean (ruff+format+mypy strict, 22 src files), run_s10.py/runs/ byte-untouched. README test-count sync ×2 + budget.py belt note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
89 lines
4 KiB
Python
89 lines
4 KiB
Python
"""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)
|