Skip to content
20 changes: 14 additions & 6 deletions agents/anyplot/plugins/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
* `before_model_callback` halts **only the root** with the fixed `budget` reply when
the request has spent `AGENT_MAX_LLM_CALLS` calls or `AGENT_REQUEST_TOKEN_BUDGET`
cost-weighted tokens (`ledger.BUDGET_WEIGHTS`), or the user or the service has spent
its daily ones. A halt inside the adapter or the reviewer would break their schema
parsing, so the pipeline checks `ledger.budget_allows` before each `run_node` and
finishes with `PlotResult(failed, reason=budget)` instead. Before a review it keeps
its daily ones: `service_paused` when only the service's day budget is spent, and
`locked_out` past the day's strikes (`ledger.day_refusal`). A halt inside the adapter
or the reviewer would break their schema parsing, so the pipeline checks
`ledger.budget_allows` before each `run_node` and finishes with
`PlotResult(failed, reason=budget)` instead. Before a review it keeps
room for the review and the root's closing reply (`pipeline.REVIEW_RESERVE_TOKENS`,
the measured need), and once a plot has shipped in the invocation
(`ledger.plot_shipped`) the root's next call is exempt from the halt, once and with
Expand Down Expand Up @@ -50,8 +52,10 @@
attribution,
budget_allows,
budget_tokens,
day_refusal,
finish_name,
ledger_for,
request_ok,
usage_breakdown,
usage_tokens,
)
Expand Down Expand Up @@ -121,6 +125,7 @@ async def before_model_callback(
ledger = ledger_for(callback_context.invocation_id)
if callback_context.agent_name != ROOT_AGENT:
return None
code = "budget"
try:
settings = get_settings()
if not ledger.user_id:
Expand All @@ -130,12 +135,15 @@ async def before_model_callback(
strip_tools(llm_request)
attribution("budget_exempt", ledger, agent=callback_context.agent_name)
return None
if budget_allows(ledger, get_services().usage, settings):
usage = get_services().usage
if budget_allows(ledger, usage, settings):
return None
if request_ok(ledger, settings): # the day's budgets, not the request's: say which one
code = day_refusal(usage, ledger.user_id or "anonymous", settings, tier=ledger.tier)
except Exception as exc:
logger.warning("budget check failed: %s", type(exc).__name__)
text = refusal("budget", ledger.lang)
ledger.refuse("budget", text)
text = refusal(code, ledger.lang)
ledger.refuse(code, text)
attribution("budget_halt", ledger, agent=callback_context.agent_name)
return budget_response(text)

Expand Down
130 changes: 129 additions & 1 deletion agents/anyplot/plugins/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
windows of `.plot_all()` copy 45 datasets within a minute, and the store's byte cap still
refuses a copy it has no room for.

The global day budget binds the whole service: once it is spent (`UsageBook.paused`),
every user gets the `service_paused` refusal (`day_refusal`) until 00:00 UTC, told apart
from a user's own spent budget (`budget`, a paste's `daily_limit`). `UsageBook.snapshot`
answers the admin's live view of the day (`GET /v1/budget`): the global spend and how many
users are at their own limits, counts only, for the instance's lifetime.

The request, user-day and global-day budgets count cost-weighted tokens: each kind
weighted by its price relative to an uncached input token (`BUDGET_WEIGHTS`), so a
cache read counts a tenth and an output token five times. `RequestLedger.tokens` and
Expand All @@ -52,7 +58,7 @@
from collections.abc import Awaitable, Callable
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any, Literal

from ..schemas import Theme
Expand Down Expand Up @@ -252,6 +258,23 @@ def today() -> str:
return datetime.now(UTC).date().isoformat()


def utc_stamp(moment: datetime) -> str:
"""An instant as the API writes one: ISO 8601 in UTC to the second, with a `Z`."""
return moment.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")


def next_reset(now: datetime | None = None) -> datetime:
"""The next 00:00 UTC: when the usage book starts a new day and every daily counter, the global one included,
starts again from zero."""
current = (now or datetime.now(UTC)).astimezone(UTC)
return datetime.combine(current.date() + timedelta(days=1), datetime.min.time(), UTC)


SERVICE_PAUSED = "service_paused"
"""The refusal and the paste route's `429` once the global day budget is spent: the whole service pauses until
00:00 UTC, whatever the user's own counts (owner request of 2026-11-02: tell it apart from the user's own limit)."""


@dataclass
class _Day:
date: str
Expand All @@ -265,6 +288,8 @@ class _Day:
user_conversions: dict[str, int] = field(default_factory=dict)
user_outcomes: dict[str, int] = field(default_factory=dict)
user_copies: dict[str, int] = field(default_factory=dict)
admins: set[str] = field(default_factory=set)
"""The users who came in the admin tier today (`mark_admin`): the book has no tier of its own, each request does."""


MESSAGE_WINDOW_S = 60.0
Expand All @@ -287,6 +312,30 @@ class _Day:
settings, like the outcome beacons'. An admin is exempt from both (owner decision of 2026-11-01, `copy_limit`)."""


LimitKind = Literal["tokens", "runs", "data_runs", "messages", "pastes", "conversions", "locked_out"]
"""The day caps of one user that `UsageBook.snapshot` counts users at: the token budget, the pipeline runs, the
`run_data_code` runs, the chat turns, the pastes, the file conversions, and the `attack` strikes."""
LIMIT_KINDS: tuple[LimitKind, ...] = ("tokens", "runs", "data_runs", "messages", "pastes", "conversions", "locked_out")


@dataclass(frozen=True)
class DaySnapshot:
"""The usage book's day as the admin's live view reads it (`GET /v1/budget`): counts only, never a user id."""

date: str
since: datetime
"""When this day's counts began: the book's start when the instance started today, else 00:00 UTC."""
started_at: datetime
"""When this instance's usage book started: a redeploy or a scale-to-zero starts a new one at zero."""
global_tokens: int
users: int
"""Users with a chat turn, a paste, a conversion, a run, booked tokens or a strike today."""
at_limit: int
"""Users at one or more of their own day caps (`limits`), each counted once."""
limits: dict[LimitKind, int]
"""Users at each day cap; a user at two caps counts under both, an admin never under one they are exempt from."""


class UsageBook:
"""Per-user and global daily counters for this instance's lifetime, and each user's messages and pastes of the
last minute."""
Expand All @@ -295,6 +344,8 @@ def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
self._day = _Day(today())
self._clock = clock
self._recent: dict[tuple[WindowKind, str], deque[float]] = {}
self.started_at = datetime.now(UTC)
"""When the book began counting: in production the instance's first request (`services.get_services`)."""

def _current(self) -> _Day:
date = today()
Expand Down Expand Up @@ -351,6 +402,51 @@ def locked_out(self, user_id: str, settings: AgentSettings) -> bool:
`daily_ok` folds into its answer, told apart from a spent token budget."""
return self.user_strikes(user_id) >= settings.attack_strikes

def paused(self, settings: AgentSettings) -> bool:
"""Whether the service's day budget (`AGENT_GLOBAL_DAILY_TOKEN_BUDGET`) is spent: the `service_paused` refusal
for everyone until 00:00 UTC, which `daily_ok` folds into its answer."""
return self.global_tokens() >= settings.global_daily_token_budget

def mark_admin(self, user_id: str) -> None:
"""Remember for today that this user came in the admin tier, for `snapshot`: `agents/main.py` `_stamp`, which
every route that spends passes."""
self._current().admins.add(user_id)

def snapshot(self, settings: AgentSettings) -> DaySnapshot:
"""Today's global spend and how many users are at their own day caps, as counts (`DaySnapshot`). An admin
counts only at the caps that bind every tier: the pastes, the conversions and the strikes."""
day = self._current()
reached: dict[LimitKind, set[str]] = {
"tokens": _at_least(day.user_tokens, settings.daily_token_budget) - day.admins,
"runs": _at_least(day.user_runs, settings.daily_pipeline_runs) - day.admins,
"data_runs": _at_least(day.user_data_runs, settings.daily_data_runs) - day.admins,
"messages": _at_least(day.user_messages, settings.user_messages_per_day) - day.admins,
"pastes": _at_least(day.user_pastes, settings.user_pastes_per_day),
"conversions": _at_least(day.user_conversions, settings.user_conversions_per_day),
"locked_out": {
user for user, strikes in day.user_strikes.items() if len(strikes) >= settings.attack_strikes
},
}
users = set().union(
day.user_tokens,
day.user_runs,
day.user_data_runs,
day.user_messages,
day.user_pastes,
day.user_conversions,
day.user_strikes,
)
midnight = datetime.fromisoformat(day.date).replace(tzinfo=UTC)
return DaySnapshot(
date=day.date,
since=max(self.started_at, midnight),
started_at=self.started_at,
global_tokens=day.global_tokens,
users=len(users),
at_limit=len(set().union(*reached.values())),
limits={kind: len(reached[kind]) for kind in LIMIT_KINDS},
)

def runs_ok(self, user_id: str, settings: AgentSettings, tier: Tier = "public") -> bool:
"""Whether the user may start another pipeline run today (`AGENT_DAILY_PIPELINE_RUNS`); always for an admin."""
return tier == "admin" or self.user_runs(user_id) < settings.daily_pipeline_runs
Expand Down Expand Up @@ -486,6 +582,22 @@ def _trimmed(self, kind: WindowKind, user_id: str) -> deque[float] | None:
return recent


def _at_least(counts: dict[str, int], cap: int) -> set[str]:
return {user for user, count in counts.items() if count >= cap}


def day_refusal(usage: UsageBook, user_id: str, settings: AgentSettings, *, tier: Tier = "public") -> str:
"""Which refusal a failed `daily_ok` is: `locked_out` past the day's strikes, `budget` when the user's own day
budget is spent, `service_paused` when only the service's is (its text says the user's own limit is untouched).
An admin has no own day budget (`UsageBook.daily_ok`), so only the service's can stop them."""
if usage.locked_out(user_id, settings):
return "locked_out"
own_left = tier == "admin" or usage.user_tokens(user_id) < settings.daily_token_budget
if own_left and usage.paused(settings):
return SERVICE_PAUSED
return "budget"


def request_ok(ledger: RequestLedger, settings: AgentSettings, *, next_calls: int = 1, reserve: int = 0) -> bool:
"""Whether the request may make `next_calls` more LLM calls and still has `reserve` cost-weighted tokens left."""
return (
Expand Down Expand Up @@ -575,6 +687,22 @@ def usage_breakdown(usage: Any) -> dict[str, int]:
160,000 of `AGENT_REQUEST_TOKEN_BUDGET` stays about 36 % above the costliest of these."""


UNIT_PRICES: dict[str, float] = {"claude-haiku-5-5": 0.10, "gemini-3.8-flash": 1.50}
"""The uncached input list price, USD per million tokens at the `global` endpoint, of each main model the budgets
are counted in: one weighted token (`BUDGET_WEIGHTS`, `JUDGE_WEIGHTS`) costs this much. The prices of
`agents/evals/pricing.py`, which the image does not ship (a test keeps both equal)."""
LOCATION_SURCHARGES: dict[str, float] = {"global": 0.0, "eu": 0.10, "us": 0.10}
"""`pricing.LOCATION_SURCHARGE`: the multi-regions add 10 % to the global list price (a test keeps both equal)."""


def weighted_usd(tokens: int, model: str, location: str) -> float | None:
"""Weighted tokens of `model` at `location` in USD at list price; None for a model without a price here."""
price = UNIT_PRICES.get(model)
if price is None:
return None
return tokens * price * (1.0 + LOCATION_SURCHARGES.get(location, 0.0)) / 1e6


def weighted_tokens(*, uncached: int = 0, cached: int = 0, cache_write: int = 0, output: int = 0) -> int:
"""Token counts in input-token equivalents (`BUDGET_WEIGHTS`), rounded to a whole token."""
weights = BUDGET_WEIGHTS
Expand Down
10 changes: 6 additions & 4 deletions agents/anyplot/plugins/scope_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* a structured action (`RequestLedger.kind == "action"`, set by the `/v1` route for
"Create plot") is not judged;
* a message with a non-text part is withheld with the `unsupported_content` refusal;
* the per-user and global daily budget is checked before the judge runs, and the
judge's tokens are booked to the ledger and the usage book;
* the per-user and global daily budget is checked before the judge runs (the
`service_paused` refusal when only the global one is spent, `ledger.day_refusal`),
and the judge's tokens are booked to the ledger and the usage book;
* the judge sees all text parts inside `<user_message>`, the root's last real reply
(at most 500 characters; never a fixed refusal or the outage line, `is_service_text`)
inside `<last_assistant_turn>`, and the user's last three
Expand Down Expand Up @@ -97,7 +98,7 @@
from ..services import KeptRefusal, get_services
from ..session_state import LANGUAGE_SOURCE, SPEC_ID, language_delta
from ..settings import get_settings
from .ledger import RequestLedger, attribution, judge_budget_tokens, ledger_for
from .ledger import RequestLedger, attribution, day_refusal, judge_budget_tokens, ledger_for


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -289,7 +290,8 @@ async def _judge(
settings = get_settings()
user = ledger.user_id or invocation_context.session.user_id
if not services.usage.daily_ok(user, settings, tier=ledger.tier):
reason = LOCKED_OUT if services.usage.locked_out(user, settings) else "budget"
# `locked_out`, `service_paused` or `budget`
reason = day_refusal(services.usage, user, settings, tier=ledger.tier)
ledger.refuse(reason, refusal(reason, ledger.lang))
attribution("scope_guard", ledger, verdict=reason)
return _withheld()
Expand Down
16 changes: 16 additions & 0 deletions agents/anyplot/prompts/refusals.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ budget:
vi: "Hiện đã đạt giới hạn sử dụng. Bạn vui lòng thử lại sau."
ru: "Лимит использования пока исчерпан. Пожалуйста, попробуй ещё раз позже."

# The service's day budget (`AGENT_GLOBAL_DAILY_TOKEN_BUDGET`) is spent, not the user's own:
# every user gets this until the usage book's new day at 00:00 UTC. The page adds the time
# in the user's own time zone next to it (the server does not know the zone).
service_paused:
en: "The chat has reached today's overall limit and is paused until 00:00 UTC. Your own limit is not affected."
de: "Der Chat hat das heutige Gesamtlimit erreicht und ist bis 00:00 UTC pausiert. Dein eigenes Limit ist davon nicht betroffen."
fr: "Le chat a atteint la limite globale du jour et est en pause jusqu'à 00:00 UTC. Ta propre limite n'est pas concernée."
es: "El chat ha alcanzado el límite global de hoy y está en pausa hasta las 00:00 UTC. Tu propio límite no se ve afectado."
it: "La chat ha raggiunto il limite complessivo di oggi ed è in pausa fino alle 00:00 UTC. Il tuo limite personale non è interessato."
pt: "O chat atingiu o limite geral de hoje e está pausado até 00:00 UTC. O seu próprio limite não é afetado."
nl: "De chat heeft de totale limiet van vandaag bereikt en is gepauzeerd tot 00:00 UTC. Je eigen limiet blijft onaangetast."
pl: "Czat osiągnął dzisiejszy limit ogólny i jest wstrzymany do 00:00 UTC. Twój własny limit pozostaje bez zmian."
zh: "聊天已达到今天的总上限,暂停至 UTC 00:00。你自己的额度不受影响。"
vi: "Cuộc trò chuyện đã đạt giới hạn chung của hôm nay và tạm dừng đến 00:00 UTC. Giới hạn của riêng bạn không bị ảnh hưởng."
ru: "Чат достиг общего лимита на сегодня и приостановлен до 00:00 UTC. Твой личный лимит не затронут."

rate_limited:
en: "That is too many messages in a short time. Please wait a minute and try again."
de: "Das sind zu viele Nachrichten in kurzer Zeit. Bitte warte eine Minute und versuche es dann noch einmal."
Expand Down
4 changes: 4 additions & 0 deletions agents/anyplot/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ def _library(value: str) -> str:
"capacity",
"rate_limited",
"daily_limit",
"service_paused",
"locked_out",
"file_refused",
"file_failed",
Expand Down Expand Up @@ -255,10 +256,12 @@ def _library(value: str) -> str:
"locked_out",
"session_closed",
"budget",
"service_paused",
"rate_limited",
"daily_limit",
"other",
]
"""`service_paused` (since 2026-11-02): the service's day budget is spent, not the user's own (`ledger.day_refusal`)."""
ErrorCode = Literal["guard_unavailable", "capacity", "run_limit", "deadline", "internal", "cancelled", "other"]
"""`cancelled`: the client closed the stream before the turn ended (the page's cancel, a reload, a lost connection);
`run_limit`: the turn's sandbox run would have been one more than `AGENT_USER_MAX_RUNS` across the user's sessions."""
Expand Down Expand Up @@ -1062,6 +1065,7 @@ def next_input(session_id: str) -> int:
"capacity": "capacity",
"rate_limited": "rate_limited",
"daily_limit": "daily_limit",
"service_paused": "service_paused",
"locked_out": "locked_out",
"convert_unavailable": "convert_unavailable",
}
Expand Down
Loading
Loading