diff --git a/.dockerignore b/.dockerignore index dbc82d28..d1dd0b94 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,3 +22,7 @@ htmlcov/ refs/ docs/ tests/ + +# Explicit frontend excludes to keep Docker context small +frontend/node_modules/ +**/node_modules/ diff --git a/app/core/usage/quota.py b/app/core/usage/quota.py index dc76079e..da0e6177 100644 --- a/app/core/usage/quota.py +++ b/app/core/usage/quota.py @@ -15,6 +15,9 @@ def apply_usage_quota( runtime_reset: float | None, secondary_used: float | None, secondary_reset: int | None, + credits_has: bool | None = None, + credits_unlimited: bool | None = None, + credits_balance: float | None = None, ) -> tuple[AccountStatus, float | None, float | None]: used_percent = primary_used reset_at = runtime_reset @@ -24,12 +27,21 @@ def apply_usage_quota( if secondary_used is not None: if secondary_used >= 100.0: - status = AccountStatus.QUOTA_EXCEEDED - used_percent = 100.0 - if secondary_reset is not None: - reset_at = secondary_reset - return status, used_percent, reset_at - if status == AccountStatus.QUOTA_EXCEEDED: + if _has_usable_credits( + credits_has=credits_has, + credits_unlimited=credits_unlimited, + credits_balance=credits_balance, + ): + if status == AccountStatus.QUOTA_EXCEEDED: + status = AccountStatus.ACTIVE + reset_at = None + else: + status = AccountStatus.QUOTA_EXCEEDED + used_percent = 100.0 + if secondary_reset is not None: + reset_at = secondary_reset + return status, used_percent, reset_at + elif status == AccountStatus.QUOTA_EXCEEDED: if runtime_reset and runtime_reset > time.time(): reset_at = runtime_reset else: @@ -62,3 +74,21 @@ def _fallback_primary_reset(primary_window_minutes: int | None) -> float | None: if not window_minutes: return None return time.time() + float(window_minutes) * 60.0 + + +def _has_usable_credits( + *, + credits_has: bool | None, + credits_unlimited: bool | None, + credits_balance: float | None, +) -> bool: + if credits_unlimited is True: + return True + if credits_has is True: + return True + if credits_balance is None: + return False + try: + return float(credits_balance) > 0.0 + except (TypeError, ValueError): + return False diff --git a/app/core/usage/refresh_scheduler.py b/app/core/usage/refresh_scheduler.py index a8a351ef..ab8d190c 100644 --- a/app/core/usage/refresh_scheduler.py +++ b/app/core/usage/refresh_scheduler.py @@ -6,10 +6,12 @@ from dataclasses import dataclass, field from app.core.config.settings import get_settings +from app.core.utils.time import utcnow from app.db.session import get_background_session from app.modules.accounts.repository import AccountsRepository from app.modules.proxy.rate_limit_cache import get_rate_limit_headers_cache -from app.modules.usage.repository import AdditionalUsageRepository, UsageRepository +from app.modules.usage.burnrate import compute_burn_rate_snapshot +from app.modules.usage.repository import AdditionalUsageRepository, BurnRateHistoryRepository, UsageRepository from app.modules.usage.updater import UsageUpdater logger = logging.getLogger(__name__) @@ -59,6 +61,31 @@ async def _refresh_once(self) -> None: accounts = await accounts_repo.list_accounts() updater = UsageUpdater(usage_repo, accounts_repo, additional_usage_repo) await updater.refresh_accounts(accounts, latest_usage) + + accounts = await accounts_repo.list_accounts() + latest_primary = await usage_repo.latest_by_account(window="primary") + latest_secondary = await usage_repo.latest_by_account(window="secondary") + burn_snapshot = compute_burn_rate_snapshot( + accounts=accounts, + latest_primary_usage=latest_primary, + latest_secondary_usage=latest_secondary, + now=utcnow(), + ) + burn_rate_repo = BurnRateHistoryRepository(session) + await burn_rate_repo.add_entry( + primary_projected_plus_accounts=burn_snapshot.primary.projected_plus_accounts, + secondary_projected_plus_accounts=burn_snapshot.secondary.projected_plus_accounts, + primary_used_plus_accounts=burn_snapshot.primary.used_plus_accounts, + secondary_used_plus_accounts=burn_snapshot.secondary.used_plus_accounts, + primary_window_minutes=burn_snapshot.primary.window_minutes, + secondary_window_minutes=burn_snapshot.secondary.window_minutes, + primary_account_count=burn_snapshot.primary.included_account_count, + secondary_account_count=burn_snapshot.secondary.included_account_count, + primary_max_plus_equivalent_accounts=burn_snapshot.primary.max_plus_equivalent_accounts, + secondary_max_plus_equivalent_accounts=burn_snapshot.secondary.max_plus_equivalent_accounts, + recorded_at=burn_snapshot.recorded_at, + ) + await get_rate_limit_headers_cache().invalidate() except Exception: logger.exception("Usage refresh loop failed") diff --git a/app/db/alembic/versions/20260319_130000_add_burn_rate_history.py b/app/db/alembic/versions/20260319_130000_add_burn_rate_history.py new file mode 100644 index 00000000..9276b4a4 --- /dev/null +++ b/app/db/alembic/versions/20260319_130000_add_burn_rate_history.py @@ -0,0 +1,69 @@ +"""add burn_rate_history table + +Revision ID: 20260319_130000_add_burn_rate_history +Revises: 20260312_120000_add_dashboard_upstream_stream_transport +Create Date: 2026-03-19 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.engine import Connection + +# revision identifiers, used by Alembic. +revision = "20260319_130000_add_burn_rate_history" +down_revision = "20260312_120000_add_dashboard_upstream_stream_transport" +branch_labels = None +depends_on = None + + +def _table_exists(connection: Connection, table_name: str) -> bool: + inspector = sa.inspect(connection) + return inspector.has_table(table_name) + + +def _indexes(connection: Connection, table_name: str) -> set[str]: + inspector = sa.inspect(connection) + if not inspector.has_table(table_name): + return set() + return {str(index["name"]) for index in inspector.get_indexes(table_name) if index.get("name") is not None} + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "burn_rate_history"): + op.create_table( + "burn_rate_history", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("recorded_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("primary_projected_plus_accounts", sa.Float(), nullable=True), + sa.Column("secondary_projected_plus_accounts", sa.Float(), nullable=True), + sa.Column("primary_used_plus_accounts", sa.Float(), nullable=True), + sa.Column("secondary_used_plus_accounts", sa.Float(), nullable=True), + sa.Column("primary_window_minutes", sa.Integer(), nullable=True), + sa.Column("secondary_window_minutes", sa.Integer(), nullable=True), + sa.Column("primary_account_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("secondary_account_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column( + "primary_max_plus_equivalent_accounts", + sa.Float(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "secondary_max_plus_equivalent_accounts", + sa.Float(), + nullable=False, + server_default=sa.text("0"), + ), + ) + + existing_indexes = _indexes(bind, "burn_rate_history") + if "idx_burn_rate_recorded_at" not in existing_indexes: + op.create_index("idx_burn_rate_recorded_at", "burn_rate_history", ["recorded_at"]) + + +def downgrade() -> None: + op.drop_table("burn_rate_history") diff --git a/app/db/alembic/versions/20260319_140000_add_request_logs_burn_rate_columns.py b/app/db/alembic/versions/20260319_140000_add_request_logs_burn_rate_columns.py new file mode 100644 index 00000000..56d27792 --- /dev/null +++ b/app/db/alembic/versions/20260319_140000_add_request_logs_burn_rate_columns.py @@ -0,0 +1,51 @@ +"""add burn-rate columns to request_logs + +Revision ID: 20260319_140000_add_request_logs_burn_rate_columns +Revises: 20260319_130000_add_burn_rate_history +Create Date: 2026-03-19 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.engine import Connection + +# revision identifiers, used by Alembic. +revision = "20260319_140000_add_request_logs_burn_rate_columns" +down_revision = "20260319_130000_add_burn_rate_history" +branch_labels = None +depends_on = None + + +def _columns(connection: Connection, table_name: str) -> set[str]: + inspector = sa.inspect(connection) + if not inspector.has_table(table_name): + return set() + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + columns = _columns(bind, "request_logs") + if not columns: + return + + with op.batch_alter_table("request_logs") as batch_op: + if "burn_rate_5h_plus_accounts" not in columns: + batch_op.add_column(sa.Column("burn_rate_5h_plus_accounts", sa.Float(), nullable=True)) + if "burn_rate_7d_plus_accounts" not in columns: + batch_op.add_column(sa.Column("burn_rate_7d_plus_accounts", sa.Float(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + columns = _columns(bind, "request_logs") + if not columns: + return + + with op.batch_alter_table("request_logs") as batch_op: + if "burn_rate_7d_plus_accounts" in columns: + batch_op.drop_column("burn_rate_7d_plus_accounts") + if "burn_rate_5h_plus_accounts" in columns: + batch_op.drop_column("burn_rate_5h_plus_accounts") diff --git a/app/db/models.py b/app/db/models.py index 2d434165..14757a35 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -107,6 +107,33 @@ class AdditionalUsageHistory(Base): recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) +class BurnRateHistory(Base): + __tablename__ = "burn_rate_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + primary_projected_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + secondary_projected_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + primary_used_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + secondary_used_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + primary_window_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + secondary_window_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + primary_account_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0")) + secondary_account_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0")) + primary_max_plus_equivalent_accounts: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0.0, + server_default=text("0"), + ) + secondary_max_plus_equivalent_accounts: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0.0, + server_default=text("0"), + ) + + class RequestLog(Base): __tablename__ = "request_logs" @@ -127,6 +154,8 @@ class RequestLog(Base): status: Mapped[str] = mapped_column(String, nullable=False) error_code: Mapped[str | None] = mapped_column(String, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + burn_rate_5h_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) + burn_rate_7d_plus_accounts: Mapped[float | None] = mapped_column(Float, nullable=True) class StickySession(Base): @@ -361,6 +390,7 @@ class ApiKeyUsageReservationItem(Base): UsageHistory.recorded_at.desc(), UsageHistory.id.desc(), ) +Index("idx_burn_rate_recorded_at", BurnRateHistory.recorded_at) Index("idx_accounts_email", Account.email) Index("idx_logs_account_time", RequestLog.account_id, RequestLog.requested_at) Index("idx_logs_requested_at", RequestLog.requested_at) diff --git a/app/modules/dashboard/repository.py b/app/modules/dashboard/repository.py index b8f2d027..e5e603c4 100644 --- a/app/modules/dashboard/repository.py +++ b/app/modules/dashboard/repository.py @@ -6,10 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.usage.types import BucketModelAggregate -from app.db.models import Account, AdditionalUsageHistory, RequestLog, UsageHistory +from app.db.models import Account, AdditionalUsageHistory, BurnRateHistory, RequestLog, UsageHistory from app.modules.accounts.repository import AccountsRepository from app.modules.request_logs.repository import RequestLogsRepository -from app.modules.usage.repository import AdditionalUsageRepository, UsageRepository +from app.modules.usage.repository import AdditionalUsageRepository, BurnRateHistoryRepository, UsageRepository class DashboardRepository: @@ -18,6 +18,7 @@ def __init__(self, session: AsyncSession) -> None: self._usage_repo = UsageRepository(session) self._logs_repo = RequestLogsRepository(session) self._additional_usage_repo = AdditionalUsageRepository(session) + self._burn_rate_repo = BurnRateHistoryRepository(session) async def list_accounts(self) -> list[Account]: return await self._accounts_repo.list_accounts() @@ -69,3 +70,7 @@ async def latest_additional_usage_by_account( async def latest_additional_recorded_at(self) -> datetime | None: return await self._additional_usage_repo.latest_recorded_at() + + + async def latest_burn_rate_entry(self) -> BurnRateHistory | None: + return await self._burn_rate_repo.latest_entry() diff --git a/app/modules/dashboard/schemas.py b/app/modules/dashboard/schemas.py index 49cbdd73..cdc513a2 100644 --- a/app/modules/dashboard/schemas.py +++ b/app/modules/dashboard/schemas.py @@ -24,6 +24,20 @@ class DepletionResponse(DashboardModel): seconds_until_exhaustion: float | None = None +class BurnRateSnapshotResponse(DashboardModel): + recorded_at: datetime + primary_projected_plus_accounts: float | None = None + secondary_projected_plus_accounts: float | None = None + primary_used_plus_accounts: float | None = None + secondary_used_plus_accounts: float | None = None + primary_window_minutes: int | None = None + secondary_window_minutes: int | None = None + primary_account_count: int = 0 + secondary_account_count: int = 0 + primary_max_plus_equivalent_accounts: float = 0.0 + secondary_max_plus_equivalent_accounts: float = 0.0 + + class DashboardOverviewResponse(DashboardModel): last_sync_at: datetime | None = None accounts: List[AccountSummary] = Field(default_factory=list) @@ -32,3 +46,4 @@ class DashboardOverviewResponse(DashboardModel): trends: MetricsTrends depletion_primary: DepletionResponse | None = None depletion_secondary: DepletionResponse | None = None + burn_rate: BurnRateSnapshotResponse | None = None diff --git a/app/modules/dashboard/service.py b/app/modules/dashboard/service.py index 2a16a2c7..6745b16b 100644 --- a/app/modules/dashboard/service.py +++ b/app/modules/dashboard/service.py @@ -6,10 +6,11 @@ from app.core.crypto import TokenEncryptor from app.core.usage.types import UsageWindowRow from app.core.utils.time import utcnow -from app.db.models import UsageHistory +from app.db.models import BurnRateHistory, UsageHistory from app.modules.accounts.mappers import build_account_summaries from app.modules.dashboard.repository import DashboardRepository from app.modules.dashboard.schemas import ( + BurnRateSnapshotResponse, DashboardOverviewResponse, DashboardUsageWindows, DepletionResponse, @@ -19,6 +20,7 @@ build_usage_summary_response, build_usage_window_response, ) +from app.modules.usage.burnrate import BurnRateSnapshot, compute_burn_rate_snapshot from app.modules.usage.depletion_service import ( compute_aggregate_depletion, compute_depletion_for_account, @@ -190,6 +192,15 @@ async def get_overview(self) -> DashboardOverviewResponse: pri_depletion, sec_depletion = _build_depletion_by_window(primary_history, secondary_history, now) additional_ts = await self._repo.latest_additional_recorded_at() + latest_burn_rate_entry = await self._repo.latest_burn_rate_entry() + computed_burn_rate = compute_burn_rate_snapshot( + accounts=accounts, + latest_primary_usage=primary_usage, + latest_secondary_usage=secondary_usage, + now=now, + ) + burn_rate = _resolve_burn_rate_response(latest_burn_rate_entry, computed_burn_rate) + return DashboardOverviewResponse( last_sync_at=_latest_recorded_at(primary_usage, secondary_usage, additional_ts), accounts=account_summaries, @@ -198,6 +209,7 @@ async def get_overview(self) -> DashboardOverviewResponse: trends=trends, depletion_primary=pri_depletion, depletion_secondary=sec_depletion, + burn_rate=burn_rate, ) @@ -280,3 +292,38 @@ def _latest_recorded_at( if additional_ts is not None: timestamps.append(additional_ts) return max(timestamps) if timestamps else None + + + +def _resolve_burn_rate_response( + latest_entry: BurnRateHistory | None, + computed_snapshot: BurnRateSnapshot, +) -> BurnRateSnapshotResponse: + if latest_entry is not None: + return BurnRateSnapshotResponse( + recorded_at=latest_entry.recorded_at, + primary_projected_plus_accounts=latest_entry.primary_projected_plus_accounts, + secondary_projected_plus_accounts=latest_entry.secondary_projected_plus_accounts, + primary_used_plus_accounts=latest_entry.primary_used_plus_accounts, + secondary_used_plus_accounts=latest_entry.secondary_used_plus_accounts, + primary_window_minutes=latest_entry.primary_window_minutes, + secondary_window_minutes=latest_entry.secondary_window_minutes, + primary_account_count=latest_entry.primary_account_count, + secondary_account_count=latest_entry.secondary_account_count, + primary_max_plus_equivalent_accounts=latest_entry.primary_max_plus_equivalent_accounts, + secondary_max_plus_equivalent_accounts=latest_entry.secondary_max_plus_equivalent_accounts, + ) + + return BurnRateSnapshotResponse( + recorded_at=computed_snapshot.recorded_at, + primary_projected_plus_accounts=computed_snapshot.primary.projected_plus_accounts, + secondary_projected_plus_accounts=computed_snapshot.secondary.projected_plus_accounts, + primary_used_plus_accounts=computed_snapshot.primary.used_plus_accounts, + secondary_used_plus_accounts=computed_snapshot.secondary.used_plus_accounts, + primary_window_minutes=computed_snapshot.primary.window_minutes, + secondary_window_minutes=computed_snapshot.secondary.window_minutes, + primary_account_count=computed_snapshot.primary.included_account_count, + secondary_account_count=computed_snapshot.secondary.included_account_count, + primary_max_plus_equivalent_accounts=computed_snapshot.primary.max_plus_equivalent_accounts, + secondary_max_plus_equivalent_accounts=computed_snapshot.secondary.max_plus_equivalent_accounts, + ) diff --git a/app/modules/proxy/load_balancer.py b/app/modules/proxy/load_balancer.py index 32a4c569..04cc799c 100644 --- a/app/modules/proxy/load_balancer.py +++ b/app/modules/proxy/load_balancer.py @@ -514,6 +514,10 @@ def _state_from_account( secondary_used = effective_secondary_entry.used_percent if effective_secondary_entry else None secondary_reset = effective_secondary_entry.reset_at if effective_secondary_entry else None + credits_source = primary_entry or effective_secondary_entry + credits_has = credits_source.credits_has if credits_source else None + credits_unlimited = credits_source.credits_unlimited if credits_source else None + credits_balance = credits_source.credits_balance if credits_source else None # Use account.reset_at from DB as the authoritative source for runtime reset # and to survive process restarts. @@ -528,6 +532,9 @@ def _state_from_account( runtime_reset=effective_runtime_reset, secondary_used=secondary_used, secondary_reset=secondary_reset, + credits_has=credits_has, + credits_unlimited=credits_unlimited, + credits_balance=credits_balance, ) return AccountState( diff --git a/app/modules/proxy/service.py b/app/modules/proxy/service.py index 1fad9f92..98a76ffd 100644 --- a/app/modules/proxy/service.py +++ b/app/modules/proxy/service.py @@ -2456,6 +2456,9 @@ async def _write_request_log( with anyio.CancelScope(shield=True): try: async with self._repo_factory() as repos: + burn_rate_5h_plus_accounts, burn_rate_7d_plus_accounts = ( + await repos.request_logs.latest_projected_burn_rates() + ) await repos.request_logs.add_log( account_id=account_id, api_key_id=api_key.id if api_key else None, @@ -2468,6 +2471,8 @@ async def _write_request_log( reasoning_effort=reasoning_effort, transport=transport, service_tier=service_tier, + burn_rate_5h_plus_accounts=burn_rate_5h_plus_accounts, + burn_rate_7d_plus_accounts=burn_rate_7d_plus_accounts, latency_ms=latency_ms, status=status, error_code=error_code, diff --git a/app/modules/request_logs/mappers.py b/app/modules/request_logs/mappers.py index 6fb83727..ab72e7d2 100644 --- a/app/modules/request_logs/mappers.py +++ b/app/modules/request_logs/mappers.py @@ -38,5 +38,7 @@ def to_request_log_entry(log: RequestLog, *, api_key_name: str | None = None) -> tokens=total_tokens_from_log(log), cached_input_tokens=cached_input_tokens_from_log(log), cost_usd=cost_from_log(log, precision=6), + burn_rate_5h_plus_accounts=log.burn_rate_5h_plus_accounts, + burn_rate_7d_plus_accounts=log.burn_rate_7d_plus_accounts, latency_ms=log.latency_ms, ) diff --git a/app/modules/request_logs/repository.py b/app/modules/request_logs/repository.py index a1b0efaa..f703b83a 100644 --- a/app/modules/request_logs/repository.py +++ b/app/modules/request_logs/repository.py @@ -11,7 +11,7 @@ from app.core.usage.types import BucketModelAggregate from app.core.utils.request_id import ensure_request_id from app.core.utils.time import utcnow -from app.db.models import Account, ApiKey, RequestLog +from app.db.models import Account, ApiKey, BurnRateHistory, RequestLog @dataclass(frozen=True, slots=True) @@ -92,6 +92,8 @@ async def add_log( reasoning_effort: str | None = None, service_tier: str | None = None, transport: str | None = None, + burn_rate_5h_plus_accounts: float | None = None, + burn_rate_7d_plus_accounts: float | None = None, api_key_id: str | None = None, ) -> RequestLog: resolved_request_id = ensure_request_id(request_id) @@ -111,6 +113,8 @@ async def add_log( status=status, error_code=error_code, error_message=error_message, + burn_rate_5h_plus_accounts=burn_rate_5h_plus_accounts, + burn_rate_7d_plus_accounts=burn_rate_7d_plus_accounts, requested_at=requested_at or utcnow(), ) self._session.add(log) @@ -124,6 +128,24 @@ async def add_log( await _safe_rollback(self._session) raise + async def latest_projected_burn_rates(self) -> tuple[float | None, float | None]: + stmt = ( + select( + BurnRateHistory.primary_projected_plus_accounts, + BurnRateHistory.secondary_projected_plus_accounts, + ) + .order_by(BurnRateHistory.recorded_at.desc(), BurnRateHistory.id.desc()) + .limit(1) + ) + try: + result = await self._session.execute(stmt) + except sa_exc.SQLAlchemyError: + return None, None + row = result.first() + if row is None: + return None, None + return row[0], row[1] + async def list_recent( self, limit: int = 50, diff --git a/app/modules/request_logs/schemas.py b/app/modules/request_logs/schemas.py index 31322e2f..90f24280 100644 --- a/app/modules/request_logs/schemas.py +++ b/app/modules/request_logs/schemas.py @@ -22,6 +22,8 @@ class RequestLogEntry(DashboardModel): cached_input_tokens: int | None = None reasoning_effort: str | None = None cost_usd: float | None = None + burn_rate_5h_plus_accounts: float | None = Field(default=None, serialization_alias="burnRate5hPlusAccounts") + burn_rate_7d_plus_accounts: float | None = Field(default=None, serialization_alias="burnRate7dPlusAccounts") latency_ms: int | None = None diff --git a/app/modules/usage/burnrate.py b/app/modules/usage/burnrate.py new file mode 100644 index 00000000..16d4727a --- /dev/null +++ b/app/modules/usage/burnrate.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from datetime import datetime +from typing import Mapping + +from app.core import usage as usage_core +from app.core.usage.types import UsageWindowRow +from app.db.models import Account, AccountStatus, UsageHistory + +PLUS_CAPACITY_CREDITS = { + "primary": 225.0, + "secondary": 7560.0, +} + + +@dataclass(frozen=True, slots=True) +class BurnRateWindowSnapshot: + projected_plus_accounts: float | None + used_plus_accounts: float | None + included_account_count: int + max_plus_equivalent_accounts: float + window_minutes: int | None + + +@dataclass(frozen=True, slots=True) +class BurnRateSnapshot: + recorded_at: datetime + primary: BurnRateWindowSnapshot + secondary: BurnRateWindowSnapshot + + +def compute_burn_rate_snapshot( + *, + accounts: list[Account], + latest_primary_usage: Mapping[str, UsageHistory], + latest_secondary_usage: Mapping[str, UsageHistory], + now: datetime, +) -> BurnRateSnapshot: + primary_rows, secondary_rows = _normalize_latest_windows(latest_primary_usage, latest_secondary_usage) + accounts_by_id = {account.id: account for account in accounts} + now_epoch = int(now.timestamp()) + + primary_snapshot = _compute_window_snapshot( + accounts_by_id=accounts_by_id, + rows_by_account=primary_rows, + window="primary", + now_epoch=now_epoch, + ) + secondary_snapshot = _compute_window_snapshot( + accounts_by_id=accounts_by_id, + rows_by_account=secondary_rows, + window="secondary", + now_epoch=now_epoch, + ) + + return BurnRateSnapshot( + recorded_at=now, + primary=primary_snapshot, + secondary=secondary_snapshot, + ) + + +def _normalize_latest_windows( + latest_primary_usage: Mapping[str, UsageHistory], + latest_secondary_usage: Mapping[str, UsageHistory], +) -> tuple[dict[str, UsageWindowRow], dict[str, UsageWindowRow]]: + primary_rows_raw = [_usage_history_to_window_row(entry) for entry in latest_primary_usage.values()] + secondary_rows_raw = [_usage_history_to_window_row(entry) for entry in latest_secondary_usage.values()] + + primary_rows, secondary_rows = usage_core.normalize_weekly_only_rows(primary_rows_raw, secondary_rows_raw) + return ( + {row.account_id: row for row in primary_rows}, + {row.account_id: row for row in secondary_rows}, + ) + + +def _usage_history_to_window_row(entry: UsageHistory) -> UsageWindowRow: + return UsageWindowRow( + account_id=entry.account_id, + used_percent=entry.used_percent, + reset_at=entry.reset_at, + window_minutes=entry.window_minutes, + recorded_at=entry.recorded_at, + ) + + +def _compute_window_snapshot( + *, + accounts_by_id: dict[str, Account], + rows_by_account: dict[str, UsageWindowRow], + window: str, + now_epoch: int, +) -> BurnRateWindowSnapshot: + included_account_count = 0 + used_plus_accounts = 0.0 + projected_plus_accounts = 0.0 + max_plus_equivalent_accounts = 0.0 + + for account_id, row in rows_by_account.items(): + account = accounts_by_id.get(account_id) + if account is None: + continue + + used_percent = _normalize_used_percent(row.used_percent) + if used_percent is None: + continue + + weight = _plus_equivalent_weight(account.plan_type, window) + used_equivalent = (used_percent / 100.0) * weight + + projected_equivalent = used_equivalent + window_minutes = _effective_window_minutes(window, row.window_minutes) + if window_minutes is not None and window_minutes > 0 and row.reset_at is not None: + window_seconds = window_minutes * 60 + seconds_until_reset = max(0, row.reset_at - now_epoch) + elapsed_seconds = max(0, window_seconds - seconds_until_reset) + if elapsed_seconds > 0: + projected_equivalent = used_equivalent * (window_seconds / elapsed_seconds) + + if window == "secondary" and account.status == AccountStatus.QUOTA_EXCEEDED: + used_equivalent = max(used_equivalent, weight) + projected_equivalent = max(projected_equivalent, weight) + + included_account_count += 1 + used_plus_accounts += used_equivalent + projected_plus_accounts += projected_equivalent + max_plus_equivalent_accounts += weight + + if included_account_count == 0: + return BurnRateWindowSnapshot( + projected_plus_accounts=None, + used_plus_accounts=None, + included_account_count=0, + max_plus_equivalent_accounts=0.0, + window_minutes=usage_core.default_window_minutes(window), + ) + + used_plus_accounts = _clamp_equivalent(used_plus_accounts, max_plus_equivalent_accounts) + projected_plus_accounts = _clamp_equivalent(projected_plus_accounts, max_plus_equivalent_accounts) + window_minutes = usage_core.resolve_window_minutes(window, rows_by_account.values()) + + return BurnRateWindowSnapshot( + projected_plus_accounts=projected_plus_accounts, + used_plus_accounts=used_plus_accounts, + included_account_count=included_account_count, + max_plus_equivalent_accounts=max_plus_equivalent_accounts, + window_minutes=window_minutes, + ) + + +def _effective_window_minutes(window: str, raw_window_minutes: int | None) -> int | None: + if raw_window_minutes is not None and raw_window_minutes > 0: + return raw_window_minutes + return usage_core.default_window_minutes(window) + + +def _normalize_used_percent(value: float | None) -> float | None: + if value is None or not isinstance(value, (int, float)): + return None + number = float(value) + if not math.isfinite(number): + return None + return max(0.0, min(100.0, number)) + + +def _plus_equivalent_weight(plan_type: str | None, window: str) -> float: + plus_capacity = PLUS_CAPACITY_CREDITS[window] + plan_capacity = usage_core.capacity_for_plan(plan_type, window) + if plan_capacity is None or not math.isfinite(plan_capacity) or plan_capacity <= 0: + # Unknown plans (e.g. enterprise seats) are treated as plus-equivalent by default. + return 1.0 + return float(plan_capacity) / plus_capacity + + +def _clamp_equivalent(value: float, max_value: float) -> float: + if not math.isfinite(value): + return 0.0 + clamped = max(0.0, value) + if not math.isfinite(max_value) or max_value <= 0: + return clamped + return min(clamped, max_value) diff --git a/app/modules/usage/repository.py b/app/modules/usage/repository.py index 945e7c6d..53e1610a 100644 --- a/app/modules/usage/repository.py +++ b/app/modules/usage/repository.py @@ -8,7 +8,7 @@ from app.core.usage.types import UsageAggregateRow, UsageTrendBucket from app.core.utils.time import utcnow -from app.db.models import AdditionalUsageHistory, UsageHistory +from app.db.models import AdditionalUsageHistory, BurnRateHistory, UsageHistory from app.modules.usage.additional_quota_keys import ( AdditionalQuotaQueryScope, canonicalize_additional_quota_key, @@ -277,6 +277,49 @@ async def latest_window_minutes(self, window: str) -> int | None: return int(value) if value is not None else None +class BurnRateHistoryRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def add_entry( + self, + *, + primary_projected_plus_accounts: float | None, + secondary_projected_plus_accounts: float | None, + primary_used_plus_accounts: float | None, + secondary_used_plus_accounts: float | None, + primary_window_minutes: int | None, + secondary_window_minutes: int | None, + primary_account_count: int, + secondary_account_count: int, + primary_max_plus_equivalent_accounts: float, + secondary_max_plus_equivalent_accounts: float, + recorded_at: datetime | None = None, + ) -> BurnRateHistory: + entry = BurnRateHistory( + primary_projected_plus_accounts=primary_projected_plus_accounts, + secondary_projected_plus_accounts=secondary_projected_plus_accounts, + primary_used_plus_accounts=primary_used_plus_accounts, + secondary_used_plus_accounts=secondary_used_plus_accounts, + primary_window_minutes=primary_window_minutes, + secondary_window_minutes=secondary_window_minutes, + primary_account_count=primary_account_count, + secondary_account_count=secondary_account_count, + primary_max_plus_equivalent_accounts=primary_max_plus_equivalent_accounts, + secondary_max_plus_equivalent_accounts=secondary_max_plus_equivalent_accounts, + recorded_at=recorded_at or utcnow(), + ) + self._session.add(entry) + await self._session.commit() + await self._session.refresh(entry) + return entry + + async def latest_entry(self) -> BurnRateHistory | None: + stmt = select(BurnRateHistory).order_by(BurnRateHistory.recorded_at.desc(), BurnRateHistory.id.desc()).limit(1) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + class AdditionalUsageRepository: def __init__(self, session: AsyncSession) -> None: self._session = session diff --git a/frontend/src/features/dashboard/components/dashboard-page.tsx b/frontend/src/features/dashboard/components/dashboard-page.tsx index fd59ee55..38115236 100644 --- a/frontend/src/features/dashboard/components/dashboard-page.tsx +++ b/frontend/src/features/dashboard/components/dashboard-page.tsx @@ -15,6 +15,7 @@ import { useDashboard } from "@/features/dashboard/hooks/use-dashboard"; import { useRequestLogs } from "@/features/dashboard/hooks/use-request-logs"; import { buildDashboardView } from "@/features/dashboard/utils"; import type { AccountSummary } from "@/features/dashboard/schemas"; +import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences"; import { useThemeStore } from "@/hooks/use-theme"; import { REQUEST_STATUS_LABELS } from "@/utils/constants"; import { formatModelLabel, formatSlug } from "@/utils/formatters"; @@ -25,6 +26,7 @@ export function DashboardPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const isDark = useThemeStore((s) => s.theme === "dark"); + const showAccountBurnrate = useDashboardPreferencesStore((s) => s.accountBurnrateEnabled); const dashboardQuery = useDashboard(); const { filters, logsQuery, optionsQuery, updateFilters } = useRequestLogs(); const { resumeMutation } = useAccountMutations(); @@ -59,8 +61,11 @@ export function DashboardPage() { if (!overview || !logPage) { return null; } - return buildDashboardView(overview, logPage.requests, isDark); - }, [overview, logPage, isDark]); + return buildDashboardView(overview, logPage.requests, { + isDark, + showAccountBurnrate, + }); + }, [overview, logPage, isDark, showAccountBurnrate]); const accountOptions = useMemo(() => { const entries = new Map(); diff --git a/frontend/src/features/dashboard/components/recent-requests-table.test.tsx b/frontend/src/features/dashboard/components/recent-requests-table.test.tsx index d1597863..76bde59e 100644 --- a/frontend/src/features/dashboard/components/recent-requests-table.test.tsx +++ b/frontend/src/features/dashboard/components/recent-requests-table.test.tsx @@ -49,6 +49,8 @@ describe("RecentRequestsTable", () => { cachedInputTokens: 200, reasoningEffort: "high", costUsd: 0.01, + burnRate5hPlusAccounts: 0.3, + burnRate7dPlusAccounts: 1.8, latencyMs: 1000, }, ]} @@ -60,6 +62,10 @@ describe("RecentRequestsTable", () => { expect(screen.getByText("gpt-5.1 (high, priority)")).toBeInTheDocument(); expect(screen.getByText("WS")).toBeInTheDocument(); expect(screen.getByText("Rate limit")).toBeInTheDocument(); + expect(screen.getByText("Burn 5h")).toBeInTheDocument(); + expect(screen.getByText("Burn 7d")).toBeInTheDocument(); + expect(screen.getByText("0.3")).toBeInTheDocument(); + expect(screen.getByText("1.8")).toBeInTheDocument(); const viewButton = screen.getByRole("button", { name: "View" }); await user.click(viewButton); @@ -95,6 +101,8 @@ describe("RecentRequestsTable", () => { cachedInputTokens: null, reasoningEffort: null, costUsd: 0, + burnRate5hPlusAccounts: null, + burnRate7dPlusAccounts: null, latencyMs: 1, }, ]} diff --git a/frontend/src/features/dashboard/components/recent-requests-table.tsx b/frontend/src/features/dashboard/components/recent-requests-table.tsx index 7afe0733..74088348 100644 --- a/frontend/src/features/dashboard/components/recent-requests-table.tsx +++ b/frontend/src/features/dashboard/components/recent-requests-table.tsx @@ -27,7 +27,6 @@ import type { AccountSummary, RequestLog } from "@/features/dashboard/schemas"; import { REQUEST_STATUS_LABELS } from "@/utils/constants"; import { formatCompactNumber, - formatCurrency, formatModelLabel, formatTimeLong, } from "@/utils/formatters"; @@ -49,6 +48,13 @@ const TRANSPORT_CLASS_MAP: Record = { websocket: "bg-sky-500/15 text-sky-700 border-sky-500/20 hover:bg-sky-500/20 dark:text-sky-300", }; +function formatBurnRate(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) { + return "--"; + } + return value.toFixed(1); +} + export type RecentRequestsTableProps = { requests: RequestLog[]; accounts: AccountSummary[]; @@ -107,7 +113,7 @@ export function RecentRequestsTable({
- +
Time @@ -117,7 +123,8 @@ export function RecentRequestsTable({ Transport Status Tokens - Cost + Burn 5h + Burn 7d Error @@ -183,7 +190,10 @@ export function RecentRequestsTable({ - {formatCurrency(request.costUsd)} + {formatBurnRate(request.burnRate5hPlusAccounts)} + + + {formatBurnRate(request.burnRate7dPlusAccounts)}
diff --git a/frontend/src/features/dashboard/components/stats-grid.test.tsx b/frontend/src/features/dashboard/components/stats-grid.test.tsx index 5756f75e..f902ca06 100644 --- a/frontend/src/features/dashboard/components/stats-grid.test.tsx +++ b/frontend/src/features/dashboard/components/stats-grid.test.tsx @@ -1,5 +1,6 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; -import { Activity, AlertTriangle, Coins, DollarSign } from "lucide-react"; +import { Activity, AlertTriangle, Coins, DollarSign, Flame } from "lucide-react"; import { describe, expect, it } from "vitest"; import { StatsGrid } from "@/features/dashboard/components/stats-grid"; @@ -8,13 +9,14 @@ const EMPTY_TREND: { value: number }[] = []; const SAMPLE_TREND = [{ value: 1 }, { value: 2 }, { value: 3 }]; describe("StatsGrid", () => { - it("renders four metric cards with values", () => { + it("renders five metric cards with values", () => { render( , @@ -26,6 +28,8 @@ describe("StatsGrid", () => { expect(screen.getByText("45K")).toBeInTheDocument(); expect(screen.getByText("Cost (7d)")).toBeInTheDocument(); expect(screen.getByText("Avg/hr $0.01")).toBeInTheDocument(); + expect(screen.getByText("Account burn rate (5h/7d)")).toBeInTheDocument(); + expect(screen.getByText("Primary 0.7 acc/5h · Secondary 0.8 acc/7d")).toBeInTheDocument(); expect(screen.getByText("Error rate")).toBeInTheDocument(); expect(screen.getByText("Top: rate_limit_exceeded")).toBeInTheDocument(); }); diff --git a/frontend/src/features/dashboard/components/stats-grid.tsx b/frontend/src/features/dashboard/components/stats-grid.tsx index 4600fa3b..7944f704 100644 --- a/frontend/src/features/dashboard/components/stats-grid.tsx +++ b/frontend/src/features/dashboard/components/stats-grid.tsx @@ -6,6 +6,7 @@ const ACCENT_STYLES = [ "bg-blue-500/10 text-blue-600 dark:bg-blue-500/15 dark:text-blue-400", "bg-violet-500/10 text-violet-600 dark:bg-violet-500/15 dark:text-violet-400", "bg-emerald-500/10 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400", + "bg-rose-500/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400", "bg-amber-500/10 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400", ]; @@ -14,8 +15,10 @@ export type StatsGridProps = { }; export function StatsGrid({ stats }: StatsGridProps) { + const columnsClass = stats.length >= 5 ? "xl:grid-cols-5" : "xl:grid-cols-4"; + return ( -
+
{stats.map((stat, index) => { const Icon = stat.icon; const accent = ACCENT_STYLES[index % ACCENT_STYLES.length]; diff --git a/frontend/src/features/dashboard/schemas.test.ts b/frontend/src/features/dashboard/schemas.test.ts index d2c7e8da..3b3dc13c 100644 --- a/frontend/src/features/dashboard/schemas.test.ts +++ b/frontend/src/features/dashboard/schemas.test.ts @@ -130,6 +130,8 @@ describe("RequestLogsResponseSchema", () => { cachedInputTokens: 0, reasoningEffort: null, costUsd: 0.001, + burnRate5hPlusAccounts: 0.4, + burnRate7dPlusAccounts: 1.6, latencyMs: 42, }, ], @@ -139,6 +141,8 @@ describe("RequestLogsResponseSchema", () => { expect(parsed.requests[0]?.apiKeyName).toBe("Key A"); expect(parsed.requests[0]?.transport).toBe("websocket"); + expect(parsed.requests[0]?.burnRate5hPlusAccounts).toBe(0.4); + expect(parsed.requests[0]?.burnRate7dPlusAccounts).toBe(1.6); }); }); diff --git a/frontend/src/features/dashboard/schemas.ts b/frontend/src/features/dashboard/schemas.ts index 273aa21c..7c3726e7 100644 --- a/frontend/src/features/dashboard/schemas.ts +++ b/frontend/src/features/dashboard/schemas.ts @@ -62,6 +62,20 @@ export const DepletionSchema = z.object({ secondsUntilExhaustion: z.number().nullable().optional(), }); +export const BurnRateSnapshotSchema = z.object({ + recordedAt: z.string().datetime({ offset: true }), + primaryProjectedPlusAccounts: z.number().nullable(), + secondaryProjectedPlusAccounts: z.number().nullable(), + primaryUsedPlusAccounts: z.number().nullable(), + secondaryUsedPlusAccounts: z.number().nullable(), + primaryWindowMinutes: z.number().nullable(), + secondaryWindowMinutes: z.number().nullable(), + primaryAccountCount: z.number().int().nonnegative(), + secondaryAccountCount: z.number().int().nonnegative(), + primaryMaxPlusEquivalentAccounts: z.number(), + secondaryMaxPlusEquivalentAccounts: z.number(), +}); + export const DashboardOverviewSchema = z.object({ lastSyncAt: z.string().datetime({ offset: true }).nullable(), accounts: z.array(AccountSummarySchema), @@ -79,6 +93,7 @@ export const DashboardOverviewSchema = z.object({ additionalQuotas: z.array(AccountAdditionalQuotaSchema).default([]), depletionPrimary: DepletionSchema.nullable().optional(), depletionSecondary: DepletionSchema.nullable().optional(), + burnRate: BurnRateSnapshotSchema.nullable().optional(), }); export const RequestLogSchema = z.object({ @@ -96,6 +111,8 @@ export const RequestLogSchema = z.object({ cachedInputTokens: z.number().nullable(), reasoningEffort: z.string().nullable(), costUsd: z.number().nullable(), + burnRate5hPlusAccounts: z.number().nullable().optional().default(null), + burnRate7dPlusAccounts: z.number().nullable().optional().default(null), latencyMs: z.number().nullable(), }); diff --git a/frontend/src/features/dashboard/utils.test.ts b/frontend/src/features/dashboard/utils.test.ts index 76242b81..0b6d54fc 100644 --- a/frontend/src/features/dashboard/utils.test.ts +++ b/frontend/src/features/dashboard/utils.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { + buildDashboardView, buildDepletionView, buildRemainingItems, } from "@/features/dashboard/utils"; import type { AccountSummary, Depletion } from "@/features/dashboard/schemas"; +import { createAccountSummary, createDashboardOverview, createDefaultRequestLogs } from "@/test/mocks/factories"; import { formatCompactAccountId } from "@/utils/account-identifiers"; function account(overrides: Partial & Pick): AccountSummary { @@ -123,3 +125,210 @@ describe("buildRemainingItems", () => { expect(items[2].isEmail).toBe(true); }); }); + +describe("buildDashboardView", () => { + it("adds plus-burn stat between cost and error rate", () => { + const overview = createDashboardOverview(); + const logs = createDefaultRequestLogs(); + + const view = buildDashboardView(overview, logs); + + expect(view.stats[2].label).toBe("Cost (7d)"); + expect(view.stats[3].label).toBe("Account burn rate (5h/7d)"); + expect(view.stats[3].value).toBe("0.7 / 1.2"); + expect(view.stats[3].meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); + expect(view.stats[3].trend.length).toBeGreaterThan(0); + expect(view.stats[4].label).toBe("Error rate"); + }); + + it("falls back to usage equivalents when depletion data is missing", () => { + const overview = createDashboardOverview({ + depletionPrimary: null, + depletionSecondary: null, + burnRate: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.label).toBe("Account burn rate (5h/7d)"); + expect(burn.value).toBe("0.7 / 1.2"); + expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); + expect(burn.trend.length).toBeGreaterThan(0); + }); + + it("projects burn rate to full 7d window when reset is still in the future", () => { + const now = Date.now(); + const overview = createDashboardOverview({ + accounts: [ + createAccountSummary({ + accountId: "acc-idle", + email: "idle@example.com", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 100, + }, + resetAtSecondary: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + createAccountSummary({ + accountId: "acc-hot", + email: "hot@example.com", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 16, + }, + resetAtSecondary: new Date(now + 74 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + ], + depletionPrimary: null, + depletionSecondary: null, + burnRate: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.0 / 1.5"); + expect(burn.meta).toBe("Primary 0.0 acc/5h · Secondary 1.5 acc/7d"); + }); + + it("counts quota_exceeded secondary accounts as fully burned and caps to account count", () => { + const now = Date.now(); + const overview = createDashboardOverview({ + accounts: [ + createAccountSummary({ + accountId: "acc-quota", + email: "quota@example.com", + status: "quota_exceeded", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 100, + }, + resetAtSecondary: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + createAccountSummary({ + accountId: "acc-hot", + email: "hot@example.com", + status: "active", + usage: { + primaryRemainingPercent: 100, + secondaryRemainingPercent: 16, + }, + resetAtSecondary: new Date(now + 74 * 60 * 60 * 1000).toISOString(), + windowMinutesSecondary: 10_080, + }), + ], + depletionPrimary: null, + depletionSecondary: null, + burnRate: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.0 / 2.0"); + expect(burn.meta).toBe("Primary 0.0 acc/5h · Secondary 2.0 acc/7d"); + }); + + it("uses usage-equivalent fallback when burn rate is zero", () => { + const overview = createDashboardOverview({ + depletionSecondary: { + risk: 1, + riskLevel: "critical", + burnRate: 0, + safeUsagePercent: 98, + projectedExhaustionAt: null, + secondsUntilExhaustion: null, + }, + burnRate: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.7 / 1.2"); + expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 1.2 acc/7d"); + }); + + it("treats unknown plans as plus-equivalent in burnrate fallbacks", () => { + const overview = createDashboardOverview({ + accounts: [ + createAccountSummary({ + accountId: "acc-enterprise", + email: "enterprise@example.com", + planType: "enterprise", + usage: { + primaryRemainingPercent: 10, + secondaryRemainingPercent: 10, + }, + }), + createAccountSummary({ + accountId: "acc-plus", + email: "plus@example.com", + planType: "plus", + usage: { + primaryRemainingPercent: 80, + secondaryRemainingPercent: 20, + }, + }), + ], + depletionPrimary: null, + depletionSecondary: null, + burnRate: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("1.1 / 1.7"); + expect(burn.meta).toBe("Primary 1.1 acc/5h · Secondary 1.7 acc/7d"); + }); + + + it("uses backend burnrate snapshot when available", () => { + const overview = createDashboardOverview({ + burnRate: { + recordedAt: new Date().toISOString(), + primaryProjectedPlusAccounts: 0.3, + secondaryProjectedPlusAccounts: 0.9, + primaryUsedPlusAccounts: 0.3, + secondaryUsedPlusAccounts: 0.9, + primaryWindowMinutes: 300, + secondaryWindowMinutes: 10_080, + primaryAccountCount: 2, + secondaryAccountCount: 2, + primaryMaxPlusEquivalentAccounts: 2, + secondaryMaxPlusEquivalentAccounts: 2, + }, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.3 / 0.9"); + expect(burn.meta).toBe("Primary 0.3 acc/5h · Secondary 0.9 acc/7d"); + }); + + it("caps burn-equivalent to available account count per window", () => { + const overview = createDashboardOverview({ + depletionSecondary: { + risk: 1, + riskLevel: "critical", + burnRate: 999, + safeUsagePercent: 98, + projectedExhaustionAt: null, + secondsUntilExhaustion: null, + }, + burnRate: null, + }); + + const view = buildDashboardView(overview, createDefaultRequestLogs()); + const burn = view.stats[3]; + + expect(burn.value).toBe("0.7 / 2.0"); + expect(burn.meta).toBe("Primary 0.7 acc/5h · Secondary 2.0 acc/7d"); + }); +}); diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts index cc5a030c..35896cb0 100644 --- a/frontend/src/features/dashboard/utils.ts +++ b/frontend/src/features/dashboard/utils.ts @@ -1,4 +1,4 @@ -import { Activity, AlertTriangle, Coins, DollarSign } from "lucide-react"; +import { Activity, AlertTriangle, Coins, DollarSign, Flame } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { buildDonutPalette } from "@/utils/colors"; @@ -20,6 +20,25 @@ import type { UsageWindow, } from "@/features/dashboard/schemas"; +const PLUS_DEFAULT_CAPACITY = { + primary: 225, + secondary: 7560, +} as const; + +const PRIMARY_PLUS_EQUIVALENT_BY_PLAN: Record = { + plus: 1, + business: 1, + team: 1, + pro: 1500 / PLUS_DEFAULT_CAPACITY.primary, +}; + +const SECONDARY_PLUS_EQUIVALENT_BY_PLAN: Record = { + plus: 1, + business: 1, + team: 1, + pro: 50400 / PLUS_DEFAULT_CAPACITY.secondary, +}; + export type RemainingItem = { accountId: string; label: string; @@ -55,6 +74,24 @@ export type DashboardView = { safeLineSecondary: SafeLineView | null; }; +type DashboardViewOptions = { + isDark?: boolean; + showAccountBurnrate?: boolean; +}; + +function resolveDashboardViewOptions(optionsOrIsDark: DashboardViewOptions | boolean): Required { + if (typeof optionsOrIsDark === "boolean") { + return { + isDark: optionsOrIsDark, + showAccountBurnrate: true, + }; + } + return { + isDark: optionsOrIsDark.isDark ?? false, + showAccountBurnrate: optionsOrIsDark.showAccountBurnrate ?? true, + }; +} + export function buildDepletionView(depletion: Depletion | null | undefined): SafeLineView | null { if (!depletion || depletion.riskLevel === "safe") return null; return { safePercent: depletion.safeUsagePercent, riskLevel: depletion.riskLevel }; @@ -123,7 +160,207 @@ export function avgPerHour(cost7d: number, hours = 24 * 7): number { return cost7d / hours; } -const TREND_COLORS = ["#3b82f6", "#8b5cf6", "#10b981", "#f59e0b"]; +function isFiniteNumber(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +function plusEquivalentWeight(planType: string | null | undefined, windowKey: "primary" | "secondary"): number { + if (!planType) { + return 1; + } + const normalized = planType.trim().toLowerCase(); + if (!normalized) { + return 1; + } + if (windowKey === "primary") { + return PRIMARY_PLUS_EQUIVALENT_BY_PLAN[normalized] ?? 1; + } + return SECONDARY_PLUS_EQUIVALENT_BY_PLAN[normalized] ?? 1; +} + +function windowUsedAccountEquivalents( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number | null { + let usedEquivalent = 0; + let includedAccounts = 0; + + for (const account of overview.accounts) { + const weight = plusEquivalentWeight(account.planType, windowKey); + const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; + const remainingPercent = + windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; + + if (windowMinutes == null || !isFiniteNumber(remainingPercent)) { + continue; + } + + let accountEquivalent = ((100 - clampPercent(remainingPercent)) / 100) * weight; + if (windowKey === "secondary" && account.status === "quota_exceeded") { + accountEquivalent = Math.max(accountEquivalent, weight); + } + + usedEquivalent += accountEquivalent; + includedAccounts += 1; + } + + return includedAccounts > 0 ? usedEquivalent : null; +} + +function windowProjectedAccountEquivalents( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number | null { + let projectedEquivalent = 0; + let includedAccounts = 0; + const nowMs = Date.now(); + + for (const account of overview.accounts) { + const weight = plusEquivalentWeight(account.planType, windowKey); + const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; + const remainingPercent = + windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; + const resetAt = windowKey === "primary" ? account.resetAtPrimary : account.resetAtSecondary; + + if (windowMinutes == null || !isFiniteNumber(remainingPercent) || windowMinutes <= 0) { + continue; + } + + const usedEquivalent = ((100 - clampPercent(remainingPercent)) / 100) * weight; + let projected = usedEquivalent; + + if (resetAt) { + const resetAtMs = Date.parse(resetAt); + if (Number.isFinite(resetAtMs)) { + const windowSeconds = windowMinutes * 60; + const secondsUntilReset = Math.max(0, (resetAtMs - nowMs) / 1000); + const elapsedSeconds = Math.max(0, windowSeconds - secondsUntilReset); + if (elapsedSeconds > 0) { + projected = usedEquivalent * (windowSeconds / elapsedSeconds); + } + } + } + + if (windowKey === "secondary" && account.status === "quota_exceeded") { + projected = Math.max(projected, weight); + } + + projectedEquivalent += projected; + includedAccounts += 1; + } + + return includedAccounts > 0 ? projectedEquivalent : null; +} + +function windowIncludedAccountCount( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number { + let includedEquivalent = 0; + + for (const account of overview.accounts) { + const weight = plusEquivalentWeight(account.planType, windowKey); + const windowMinutes = windowKey === "primary" ? account.windowMinutesPrimary : account.windowMinutesSecondary; + const remainingPercent = + windowKey === "primary" ? account.usage?.primaryRemainingPercent : account.usage?.secondaryRemainingPercent; + + if (windowMinutes == null || !isFiniteNumber(remainingPercent)) { + continue; + } + + includedEquivalent += weight; + } + + return includedEquivalent; +} + +function clampBurnEquivalent(value: number | null, maxEquivalent: number): number | null { + if (!isFiniteNumber(value)) { + return null; + } + + const clamped = Math.max(0, value); + if (maxEquivalent <= 0) { + return clamped; + } + return Math.min(clamped, maxEquivalent); +} + +function plusAccountsBurnEquivalent( + overview: DashboardOverview, + windowKey: "primary" | "secondary", +): number | null { + const summaryWindow = windowKey === "primary" ? overview.summary.primaryWindow : overview.summary.secondaryWindow; + const depletion = windowKey === "primary" ? overview.depletionPrimary : overview.depletionSecondary; + const maxEquivalent = windowIncludedAccountCount(overview, windowKey); + const fallbackProjectedEquivalent = clampBurnEquivalent( + windowProjectedAccountEquivalents(overview, windowKey), + maxEquivalent, + ); + const fallbackUsedEquivalent = clampBurnEquivalent(windowUsedAccountEquivalents(overview, windowKey), maxEquivalent); + + if (!summaryWindow) { + return fallbackProjectedEquivalent ?? fallbackUsedEquivalent; + } + + const remainingCredits = summaryWindow.remainingCredits; + const burnRate = depletion?.burnRate; + let burnEquivalent: number | null = null; + + if (isFiniteNumber(remainingCredits) && remainingCredits >= 0 && isFiniteNumber(burnRate) && burnRate > 0) { + const plusCapacity = PLUS_DEFAULT_CAPACITY[windowKey]; + const equivalent = (remainingCredits * burnRate) / plusCapacity; + if (isFiniteNumber(equivalent)) { + burnEquivalent = Math.max(0, equivalent); + } + } + + burnEquivalent = clampBurnEquivalent(burnEquivalent, maxEquivalent); + + if (windowKey === "secondary") { + if (isFiniteNumber(fallbackProjectedEquivalent)) { + return clampBurnEquivalent( + burnEquivalent === null ? fallbackProjectedEquivalent : Math.max(burnEquivalent, fallbackProjectedEquivalent), + maxEquivalent, + ); + } + if (isFiniteNumber(fallbackUsedEquivalent)) { + return clampBurnEquivalent( + burnEquivalent === null ? fallbackUsedEquivalent : Math.max(burnEquivalent, fallbackUsedEquivalent), + maxEquivalent, + ); + } + } + + return burnEquivalent ?? fallbackProjectedEquivalent ?? fallbackUsedEquivalent; +} + +function formatBurnEquivalent(value: number | null): string { + if (value === null || !Number.isFinite(value)) { + return "--"; + } + return value.toFixed(1); +} + +function buildBurnTrend(points: TrendPoint[], currentValue: number | null): { value: number }[] { + if (currentValue === null || !Number.isFinite(currentValue) || currentValue <= 0 || points.length === 0) { + return []; + } + + const lastPoint = points[points.length - 1]?.v ?? 0; + if (!Number.isFinite(lastPoint) || lastPoint <= 0) { + return points.map(() => ({ value: currentValue })); + } + + const scale = currentValue / lastPoint; + return points.map((point) => ({ value: Math.max(0, point.v * scale) })); +} + +const TREND_COLORS = ["#3b82f6", "#8b5cf6", "#10b981", "#ef4444", "#f59e0b"]; function trendPointsToValues(points: TrendPoint[]): { value: number }[] { return points.map((p) => ({ value: p.v })); @@ -132,15 +369,34 @@ function trendPointsToValues(points: TrendPoint[]): { value: number }[] { export function buildDashboardView( overview: DashboardOverview, requestLogs: RequestLog[], - isDark = false, + optionsOrIsDark: DashboardViewOptions | boolean = false, ): DashboardView { + const { isDark, showAccountBurnrate } = resolveDashboardViewOptions(optionsOrIsDark); const primaryWindow = overview.windows.primary; const secondaryWindow = overview.windows.secondary; const metrics = overview.summary.metrics; const cost = overview.summary.cost.totalUsd7d; const secondaryLabel = formatWindowLabel("secondary", secondaryWindow?.windowMinutes ?? null); + const burnRate = overview.burnRate; + const primaryBurnLabel = formatWindowLabel( + "primary", + burnRate?.primaryWindowMinutes ?? overview.summary.primaryWindow.windowMinutes ?? null, + ); + const secondaryBurnLabel = formatWindowLabel( + "secondary", + burnRate?.secondaryWindowMinutes ?? overview.summary.secondaryWindow?.windowMinutes ?? null, + ); const trends = overview.trends; + const fallbackPrimaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "primary"); + const fallbackSecondaryBurnEquivalent = plusAccountsBurnEquivalent(overview, "secondary"); + const primaryBurnEquivalent = burnRate?.primaryProjectedPlusAccounts ?? fallbackPrimaryBurnEquivalent; + const secondaryBurnEquivalent = burnRate?.secondaryProjectedPlusAccounts ?? fallbackSecondaryBurnEquivalent; + const combinedBurnEquivalent = + (primaryBurnEquivalent ?? 0) + (secondaryBurnEquivalent ?? 0) > 0 + ? (primaryBurnEquivalent ?? 0) + (secondaryBurnEquivalent ?? 0) + : null; + const stats: DashboardStat[] = [ { label: "Requests (7d)", @@ -166,18 +422,30 @@ export function buildDashboardView( trend: trendPointsToValues(trends.cost), trendColor: TREND_COLORS[2], }, - { - label: "Error rate", - value: formatRate(metrics?.errorRate7d ?? null), - meta: metrics?.topError - ? `Top: ${metrics.topError}` - : `~${formatCompactNumber(Math.round((metrics?.errorRate7d ?? 0) * (metrics?.requests7d ?? 0)))} errors in 7d`, - icon: AlertTriangle, - trend: trendPointsToValues(trends.errorRate), - trendColor: TREND_COLORS[3], - }, ]; + if (showAccountBurnrate) { + stats.push({ + label: `Account burn rate (${primaryBurnLabel}/${secondaryBurnLabel})`, + value: `${formatBurnEquivalent(primaryBurnEquivalent)} / ${formatBurnEquivalent(secondaryBurnEquivalent)}`, + meta: `Primary ${formatBurnEquivalent(primaryBurnEquivalent)} acc/${primaryBurnLabel} · Secondary ${formatBurnEquivalent(secondaryBurnEquivalent)} acc/${secondaryBurnLabel}`, + icon: Flame, + trend: buildBurnTrend(trends.tokens, combinedBurnEquivalent), + trendColor: TREND_COLORS[3], + }); + } + + stats.push({ + label: "Error rate", + value: formatRate(metrics?.errorRate7d ?? null), + meta: metrics?.topError + ? `Top: ${metrics.topError}` + : `~${formatCompactNumber(Math.round((metrics?.errorRate7d ?? 0) * (metrics?.requests7d ?? 0)))} errors in 7d`, + icon: AlertTriangle, + trend: trendPointsToValues(trends.errorRate), + trendColor: TREND_COLORS[4], + }); + return { stats, primaryUsageItems: buildRemainingItems(overview.accounts, primaryWindow, "primary", isDark), diff --git a/frontend/src/features/settings/components/appearance-settings.tsx b/frontend/src/features/settings/components/appearance-settings.tsx index 045eb304..01e7a997 100644 --- a/frontend/src/features/settings/components/appearance-settings.tsx +++ b/frontend/src/features/settings/components/appearance-settings.tsx @@ -1,5 +1,7 @@ import { Monitor, Moon, Palette, Sun } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences"; import { useThemeStore, type ThemePreference } from "@/hooks/use-theme"; import { cn } from "@/lib/utils"; @@ -12,6 +14,8 @@ const THEME_OPTIONS: { value: ThemePreference; label: string; icon: typeof Sun } export function AppearanceSettings() { const preference = useThemeStore((s) => s.preference); const setTheme = useThemeStore((s) => s.setTheme); + const accountBurnrateEnabled = useDashboardPreferencesStore((s) => s.accountBurnrateEnabled); + const setAccountBurnrateEnabled = useDashboardPreferencesStore((s) => s.setAccountBurnrateEnabled); return (
@@ -28,28 +32,38 @@ export function AppearanceSettings() {
-
-
-

Theme

-

Select your preferred color scheme.

+
+
+
+

Theme

+

Select your preferred color scheme.

+
+
+ {THEME_OPTIONS.map(({ value, label, icon: Icon }) => ( + + ))} +
-
- {THEME_OPTIONS.map(({ value, label, icon: Icon }) => ( - - ))} + +
+
+

Account burn rate

+

Show the account burn rate card on the dashboard.

+
+
diff --git a/frontend/src/features/settings/schemas.test.ts b/frontend/src/features/settings/schemas.test.ts index 1ed32ea8..533c9ef3 100644 --- a/frontend/src/features/settings/schemas.test.ts +++ b/frontend/src/features/settings/schemas.test.ts @@ -26,6 +26,21 @@ describe("DashboardSettingsSchema", () => { expect(parsed.importWithoutOverwrite).toBe(true); expect(parsed.apiKeyAuthEnabled).toBe(true); }); + + it("parses legacy settings payload and applies defaults for missing routing fields", () => { + const parsed = DashboardSettingsSchema.parse({ + stickyThreadsEnabled: true, + preferEarlierResetAccounts: false, + importWithoutOverwrite: false, + totpRequiredOnLogin: false, + totpConfigured: false, + apiKeyAuthEnabled: true, + }); + + expect(parsed.upstreamStreamTransport).toBe("default"); + expect(parsed.routingStrategy).toBe("usage_weighted"); + expect(parsed.openaiCacheAffinityMaxAgeSeconds).toBe(300); + }); }); describe("SettingsUpdateRequestSchema", () => { diff --git a/frontend/src/features/settings/schemas.ts b/frontend/src/features/settings/schemas.ts index 53114d04..c272abb8 100644 --- a/frontend/src/features/settings/schemas.ts +++ b/frontend/src/features/settings/schemas.ts @@ -5,10 +5,10 @@ export const UpstreamStreamTransportSchema = z.enum(["default", "auto", "http", export const DashboardSettingsSchema = z.object({ stickyThreadsEnabled: z.boolean(), - upstreamStreamTransport: UpstreamStreamTransportSchema, + upstreamStreamTransport: UpstreamStreamTransportSchema.optional().default("default"), preferEarlierResetAccounts: z.boolean(), - routingStrategy: RoutingStrategySchema, - openaiCacheAffinityMaxAgeSeconds: z.number().int().positive(), + routingStrategy: RoutingStrategySchema.optional().default("usage_weighted"), + openaiCacheAffinityMaxAgeSeconds: z.number().int().positive().optional().default(300), importWithoutOverwrite: z.boolean(), totpRequiredOnLogin: z.boolean(), totpConfigured: z.boolean(), diff --git a/frontend/src/hooks/use-dashboard-preferences.ts b/frontend/src/hooks/use-dashboard-preferences.ts new file mode 100644 index 00000000..67145b15 --- /dev/null +++ b/frontend/src/hooks/use-dashboard-preferences.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; + +const ACCOUNT_BURNRATE_STORAGE_KEY = "codex-lb-account-burnrate-enabled"; + +type DashboardPreferencesState = { + accountBurnrateEnabled: boolean; + initialized: boolean; + initializePreferences: () => void; + setAccountBurnrateEnabled: (enabled: boolean) => void; +}; + +function readStoredAccountBurnrateEnabled(): boolean | null { + if (typeof window === "undefined") { + return null; + } + const stored = window.localStorage.getItem(ACCOUNT_BURNRATE_STORAGE_KEY); + if (stored === "true") { + return true; + } + if (stored === "false") { + return false; + } + return null; +} + +function persistAccountBurnrateEnabled(enabled: boolean): void { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem(ACCOUNT_BURNRATE_STORAGE_KEY, String(enabled)); +} + +export const useDashboardPreferencesStore = create((set) => ({ + accountBurnrateEnabled: true, + initialized: false, + initializePreferences: () => { + const accountBurnrateEnabled = readStoredAccountBurnrateEnabled() ?? true; + persistAccountBurnrateEnabled(accountBurnrateEnabled); + set({ accountBurnrateEnabled, initialized: true }); + }, + setAccountBurnrateEnabled: (enabled) => { + persistAccountBurnrateEnabled(enabled); + set({ accountBurnrateEnabled: enabled, initialized: true }); + }, +})); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 72c85c24..cca098cd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,12 +4,14 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App.tsx"; +import { useDashboardPreferencesStore } from "@/hooks/use-dashboard-preferences"; import { queryClient } from "@/lib/query-client"; import { useThemeStore } from "@/hooks/use-theme"; import "./index.css"; useThemeStore.getState().initializeTheme(); +useDashboardPreferencesStore.getState().initializePreferences(); createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/test/mocks/factories.ts b/frontend/src/test/mocks/factories.ts index baad3fd2..114f762e 100644 --- a/frontend/src/test/mocks/factories.ts +++ b/frontend/src/test/mocks/factories.ts @@ -181,6 +181,19 @@ export function createDashboardOverview(overrides: Partial = projectedExhaustionAt: null, secondsUntilExhaustion: null, }, + burnRate: { + recordedAt: offsetIso(-5), + primaryProjectedPlusAccounts: 0.7, + secondaryProjectedPlusAccounts: 1.2, + primaryUsedPlusAccounts: 0.7, + secondaryUsedPlusAccounts: 1.2, + primaryWindowMinutes: 300, + secondaryWindowMinutes: 10_080, + primaryAccountCount: 2, + secondaryAccountCount: 2, + primaryMaxPlusEquivalentAccounts: 2, + secondaryMaxPlusEquivalentAccounts: 2, + }, ...overrides, }; return DashboardOverviewSchema.parse(response); @@ -202,6 +215,8 @@ export function createRequestLogEntry(overrides: Partial = {}): cachedInputTokens: 320, reasoningEffort: null, costUsd: 0.0132, + burnRate5hPlusAccounts: 0.7, + burnRate7dPlusAccounts: 1.2, latencyMs: 920, ...overrides, }); diff --git a/tests/integration/test_request_logs_api.py b/tests/integration/test_request_logs_api.py index 26dda791..8fa67431 100644 --- a/tests/integration/test_request_logs_api.py +++ b/tests/integration/test_request_logs_api.py @@ -71,6 +71,8 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): requested_at=now, api_key_id="key_logs_1", transport="websocket", + burn_rate_5h_plus_accounts=0.5, + burn_rate_7d_plus_accounts=1.7, ) response = await async_client.get("/api/request-logs?limit=2") @@ -87,6 +89,8 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): assert latest["errorCode"] == "rate_limit_exceeded" assert latest["errorMessage"] == "Rate limit reached" assert latest["transport"] == "websocket" + assert latest["burnRate5hPlusAccounts"] == pytest.approx(0.5) + assert latest["burnRate7dPlusAccounts"] == pytest.approx(1.7) older = payload[1] assert older["status"] == "ok" @@ -94,3 +98,5 @@ async def test_request_logs_api_returns_recent(async_client, db_setup): assert older["tokens"] == 300 assert older["cachedInputTokens"] is None assert older["transport"] == "http" + assert older["burnRate5hPlusAccounts"] is None + assert older["burnRate7dPlusAccounts"] is None diff --git a/tests/unit/test_load_balancer.py b/tests/unit/test_load_balancer.py index 7c5ac7c1..7d2d1d8a 100644 --- a/tests/unit/test_load_balancer.py +++ b/tests/unit/test_load_balancer.py @@ -255,6 +255,44 @@ def test_apply_usage_quota_sets_fallback_reset_for_primary_window(monkeypatch): assert reset_at == pytest.approx(now + 60.0) +def test_apply_usage_quota_secondary_exhausted_without_credits_sets_quota_exceeded(): + secondary_reset = 1_700_000_000 + status, used_percent, reset_at = apply_usage_quota( + status=AccountStatus.ACTIVE, + primary_used=40.0, + primary_reset=None, + primary_window_minutes=None, + runtime_reset=None, + secondary_used=100.0, + secondary_reset=secondary_reset, + credits_has=False, + credits_unlimited=False, + credits_balance=0.0, + ) + assert status == AccountStatus.QUOTA_EXCEEDED + assert used_percent == 100.0 + assert reset_at == secondary_reset + + +def test_apply_usage_quota_secondary_exhausted_with_credits_reactivates_account(): + future_reset = 1_700_000_000.0 + status, used_percent, reset_at = apply_usage_quota( + status=AccountStatus.QUOTA_EXCEEDED, + primary_used=40.0, + primary_reset=None, + primary_window_minutes=None, + runtime_reset=future_reset, + secondary_used=100.0, + secondary_reset=int(future_reset), + credits_has=False, + credits_unlimited=False, + credits_balance=25.0, + ) + assert status == AccountStatus.ACTIVE + assert used_percent == 40.0 + assert reset_at is None + + def test_handle_quota_exceeded_sets_used_percent(): state = AccountState("a", AccountStatus.ACTIVE, used_percent=5.0) handle_quota_exceeded(state, {}) diff --git a/tests/unit/test_usage_burnrate.py b/tests/unit/test_usage_burnrate.py new file mode 100644 index 00000000..fd7f46ce --- /dev/null +++ b/tests/unit/test_usage_burnrate.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from app.core.crypto import TokenEncryptor +from app.core.utils.time import naive_utc_to_epoch, utcnow +from app.db.models import Account, AccountStatus, UsageHistory +from app.modules.usage.burnrate import compute_burn_rate_snapshot + +pytestmark = pytest.mark.unit + + +def _make_account(account_id: str, plan_type: str, status: AccountStatus = AccountStatus.ACTIVE) -> Account: + encryptor = TokenEncryptor() + return Account( + id=account_id, + email=f"{account_id}@example.com", + plan_type=plan_type, + access_token_encrypted=encryptor.encrypt("access"), + refresh_token_encrypted=encryptor.encrypt("refresh"), + id_token_encrypted=encryptor.encrypt("id"), + last_refresh=utcnow(), + status=status, + deactivation_reason=None, + ) + + +def _usage(account_id: str, used_percent: float, *, window_minutes: int, reset_at: int, window: str) -> UsageHistory: + return UsageHistory( + account_id=account_id, + used_percent=used_percent, + window=window, + window_minutes=window_minutes, + reset_at=reset_at, + recorded_at=utcnow(), + ) + + +def test_burnrate_unknown_plan_defaults_to_plus_equivalent_weight() -> None: + now = utcnow().replace(microsecond=0) + reset_epoch = int(naive_utc_to_epoch(now)) + + account = _make_account("acc-enterprise", "enterprise") + primary = _usage(account.id, 50.0, window_minutes=300, reset_at=reset_epoch, window="primary") + secondary = _usage(account.id, 20.0, window_minutes=10080, reset_at=reset_epoch, window="secondary") + + snapshot = compute_burn_rate_snapshot( + accounts=[account], + latest_primary_usage={account.id: primary}, + latest_secondary_usage={account.id: secondary}, + now=now, + ) + + assert snapshot.primary.projected_plus_accounts == pytest.approx(0.5) + assert snapshot.secondary.projected_plus_accounts == pytest.approx(0.2) + assert snapshot.primary.max_plus_equivalent_accounts == pytest.approx(1.0) + assert snapshot.secondary.max_plus_equivalent_accounts == pytest.approx(1.0) + + +def test_burnrate_known_pro_plan_uses_plus_capacity_ratio() -> None: + now = utcnow().replace(microsecond=0) + reset_epoch = int(naive_utc_to_epoch(now)) + + account = _make_account("acc-pro", "pro") + primary = _usage(account.id, 50.0, window_minutes=300, reset_at=reset_epoch, window="primary") + secondary = _usage(account.id, 50.0, window_minutes=10080, reset_at=reset_epoch, window="secondary") + + snapshot = compute_burn_rate_snapshot( + accounts=[account], + latest_primary_usage={account.id: primary}, + latest_secondary_usage={account.id: secondary}, + now=now, + ) + + assert snapshot.primary.projected_plus_accounts == pytest.approx(1500 / 225 * 0.5) + assert snapshot.secondary.projected_plus_accounts == pytest.approx(50400 / 7560 * 0.5) + + +def test_burnrate_secondary_quota_exceeded_is_counted_as_fully_burned() -> None: + now = utcnow().replace(microsecond=0) + reset_epoch = int(naive_utc_to_epoch(now + timedelta(days=1))) + + account = _make_account("acc-plus", "plus", status=AccountStatus.QUOTA_EXCEEDED) + secondary = _usage(account.id, 0.0, window_minutes=10080, reset_at=reset_epoch, window="secondary") + + snapshot = compute_burn_rate_snapshot( + accounts=[account], + latest_primary_usage={}, + latest_secondary_usage={account.id: secondary}, + now=now, + ) + + assert snapshot.secondary.used_plus_accounts == pytest.approx(1.0) + assert snapshot.secondary.projected_plus_accounts == pytest.approx(1.0) + + +def test_burnrate_normalizes_weekly_only_primary_rows_into_secondary() -> None: + now = utcnow().replace(microsecond=0) + reset_epoch = int(naive_utc_to_epoch(now)) + + account = _make_account("acc-weekly", "free") + weekly_primary = _usage(account.id, 20.0, window_minutes=10080, reset_at=reset_epoch, window="primary") + + snapshot = compute_burn_rate_snapshot( + accounts=[account], + latest_primary_usage={account.id: weekly_primary}, + latest_secondary_usage={}, + now=now, + ) + + assert snapshot.primary.projected_plus_accounts is None + assert snapshot.secondary.projected_plus_accounts == pytest.approx(0.2)