Source code for modelly.llm.budget

"""Token and cost budgets for a run, and the counters the client keeps.

No price is built in: prices change, and a wrong one is worse than none. Pass a
price table to know the cost; without one, the cost stays unknown (None) and only a
token budget can be enforced.

Before a call is sent, its worst case is reserved: the prompt's estimated tokens plus
its ``max_tokens``. When that could take the run past a limit, :class:`BudgetExceeded`
is raised and the call is never made. Replies from the cache cost nothing and are
counted apart (``cache_hits``).

Example:
    >>> budget = Budget(max_tokens=10_000, prices={"m": Price(3.0, 15.0)}, max_cost=0.5)
    >>> budget.usage.cost
    0.0
"""

from __future__ import annotations

import math
import threading
from collections.abc import Mapping
from dataclasses import dataclass, field

from modelly.llm.errors import BudgetExceeded
from modelly.llm.request import Request, Response

# A rough count for English text, used only to reserve tokens before a call.
CHARS_PER_TOKEN = 4
# Prices are per million tokens, as providers publish them.
PER_PRICE_UNIT = 1_000_000
# Output tokens reserved when a request sets no max_tokens.
DEFAULT_RESERVED_OUTPUT = 1024


[docs] @dataclass(frozen=True) class Price: """Price per million input tokens and per million output tokens, in any currency.""" input_per_million: float output_per_million: float
[docs] def cost(self, input_tokens: int, output_tokens: int) -> float: """The price of one call.""" return ( input_tokens * self.input_per_million + output_tokens * self.output_per_million ) / PER_PRICE_UNIT
[docs] @dataclass class Usage: """What a run used so far. Attributes: calls: Calls sent to the provider (retries not included). cache_hits: Replies served from the cache. retries: Extra tries after a 429, a 5xx or a lost connection. input_tokens: Prompt tokens of the calls sent. output_tokens: Reply tokens of the calls sent. cost: The cost of the calls sent, or None when a model has no price. """ calls: int = 0 cache_hits: int = 0 retries: int = 0 input_tokens: int = 0 output_tokens: int = 0 cost: float | None = 0.0 @property def tokens(self) -> int: """Input plus output tokens.""" return self.input_tokens + self.output_tokens
[docs] @dataclass class Budget: """Limits for one run and the usage counted against them. Args: max_tokens: The most input plus output tokens the run may send for. None: no limit. max_cost: The most the run may cost. Needs a price for every model it calls. prices: Model name to :class:`Price`. """ max_tokens: int | None = None max_cost: float | None = None prices: Mapping[str, Price] = field(default_factory=dict) usage: Usage = field(default_factory=Usage) _reserved_tokens: int = field(default=0, init=False, repr=False) _reserved_cost: float = field(default=0.0, init=False, repr=False) def __post_init__(self) -> None: """Copy the price table and create the lock.""" self.prices = dict(self.prices) self._lock = threading.Lock()
[docs] def estimate(self, request: Request) -> tuple[int, int]: """Worst-case (input, output) tokens of ``request``.""" prompt = math.ceil(request.prompt_chars() / CHARS_PER_TOKEN) return prompt, request.max_tokens or DEFAULT_RESERVED_OUTPUT
[docs] def price_of(self, model: str) -> Price | None: """The price of ``model``; required when a cost budget is set.""" price = self.prices.get(model) if price is None and self.max_cost is not None: raise BudgetExceeded( f"a cost budget is set but model {model!r} has no price: pass " f"prices={{{model!r}: Price(input, output)}} or drop max_cost" ) return price
[docs] def reserve(self, request: Request) -> None: """Hold the worst case of ``request``, or raise :class:`BudgetExceeded`.""" tokens_in, tokens_out = self.estimate(request) price = self.price_of(request.model) cost = price.cost(tokens_in, tokens_out) if price else 0.0 with self._lock: tokens = self.usage.tokens + self._reserved_tokens + tokens_in + tokens_out spent = (self.usage.cost or 0.0) + self._reserved_cost + cost self.refuse_over(tokens, spent, request) self._reserved_tokens += tokens_in + tokens_out self._reserved_cost += cost
[docs] def refuse_over(self, tokens: int, cost: float, request: Request) -> None: """Raise when ``tokens`` or ``cost`` passes its limit.""" call = f"call {request.call_index} of {request.scope!r}" if self.max_tokens is not None and tokens > self.max_tokens: raise BudgetExceeded( f"{call} could bring the run to {tokens} tokens, over the budget of " f"{self.max_tokens}; {self.usage.tokens} used so far" ) if self.max_cost is not None and cost > self.max_cost: raise BudgetExceeded( f"{call} could bring the run's cost to {cost:.6f}, over the budget of " f"{self.max_cost}; {self.usage.cost:.6f} spent so far" )
[docs] def release(self, request: Request) -> None: """Give back the reservation of ``request`` once its reply is counted.""" tokens_in, tokens_out = self.estimate(request) price = self.prices.get(request.model) with self._lock: self._reserved_tokens -= tokens_in + tokens_out if price: self._reserved_cost -= price.cost(tokens_in, tokens_out)
[docs] def record(self, request: Request, response: Response, retries: int) -> None: """Count a reply from the provider.""" price = self.prices.get(request.model) with self._lock: usage = self.usage usage.calls += 1 usage.retries += retries usage.input_tokens += response.input_tokens usage.output_tokens += response.output_tokens if price is None or usage.cost is None: usage.cost = None else: usage.cost += price.cost(response.input_tokens, response.output_tokens)
[docs] def record_hit(self) -> None: """Count a reply served from the cache.""" with self._lock: self.usage.cache_hits += 1
def __getstate__(self) -> dict: """Everything but the lock.""" return {k: v for k, v in vars(self).items() if k != "_lock"} def __setstate__(self, state: dict) -> None: """Restore the fields and a fresh lock.""" vars(self).update(state) self._lock = threading.Lock()