Skip to content

symfonic.services.cost

cost

Cost and spend: port-registry row 5, and the adapter over the shipped ledger.

Distinct from :mod:symfonic.services.budget, which is the context-window budget — token estimation, allocation and truncation. This package is about money: whether a scope may incur another billable call, and what it has spent. The two were never the same concern and sharing a word for them has cost more than one reader an afternoon.

BudgetCheck

Bases: Protocol

The admission read. One method, because it answers one question.

check async

check(scope: SubjectScope, *, is_admin: bool = False) -> BudgetDecision

May this scope incur another billable call?

is_admin is a policy input, not a caller-side branch (BUD-4): the port decides whether an administrator bypasses, and the transport never skips the call.

Source code in src/symfonic/services/cost/ports.py
async def check(
    self, scope: SubjectScope, *, is_admin: bool = False
) -> BudgetDecision:
    """May this scope incur another billable call?

    ``is_admin`` is a policy *input*, not a caller-side branch (BUD-4): the
    port decides whether an administrator bypasses, and the transport never
    skips the call.
    """
    ...

BudgetDecision dataclass

BudgetDecision(allowed: bool, reason: str | None = None, code: str = 'budget_ok', daily_used_usd: float = 0.0, daily_limit_usd: float | None = None, monthly_used_usd: float = 0.0, monthly_limit_usd: float | None = None, retry_after_seconds: int = 3600)

Allowed or not, plus a stable code a transport can map on.

code is the field BUD-2 adds and the reason this type exists at all. The shipped path raises a plain HTTPException whose message begins "Budget exceeded:", and three routers branch on that prefix — so a reworded or translated message turns a 429 into a 500. A caller that reads code cannot be broken by editing prose.

CostReadModel

Bases: Protocol

The projection /metrics/* renders (BUD-6).

summarize async

summarize(scope: SubjectScope, *, window: str) -> Any

Aggregated spend for a scope and window. Tenant-scoped, always.

SEC-TEN-2 makes isolation unconditional — never gated on a collector being enabled — so an implementation that cannot scope a summary must refuse it rather than widen it.

Source code in src/symfonic/services/cost/ports.py
async def summarize(self, scope: SubjectScope, *, window: str) -> Any:
    """Aggregated spend for a scope and window. Tenant-scoped, always.

    SEC-TEN-2 makes isolation unconditional — never gated on a collector
    being enabled — so an implementation that cannot scope a summary must
    refuse it rather than widen it.
    """
    ...

CostSummary dataclass

CostSummary(scope_key: str, window: str, cost_usd: float = 0.0, input_tokens: int = 0, output_tokens: int = 0, calls: int = 0)

A projection of runtime-service accounting (BUD-6).

A read model: it carries numbers somebody else computed. The platform performs no accounting arithmetic of its own, because two places that both know how to add up a bill will eventually disagree about a rounding rule.

TrackerBudgetCheck

TrackerBudgetCheck(tracker: _TrackerLike)

BudgetCheck over TokenBudgetTracker.

The ledger is keyed by tenant id and stays that way: inventing a sub-tenant-granular key here would silently give every sub-tenant its own ceiling, which is a pricing change wearing a refactor's clothes. The subject's tenant segment is what the tracker sees, and that is recorded rather than assumed.

Source code in src/symfonic/services/cost/tracker_adapter.py
def __init__(self, tracker: _TrackerLike) -> None:
    self._tracker = tracker

TrackerCostReadModel

TrackerCostReadModel(tracker: Any)

CostReadModel over the tracker's usage aggregates (BUD-6).

Source code in src/symfonic/services/cost/tracker_adapter.py
def __init__(self, tracker: Any) -> None:
    self._tracker = tracker