Skip to content
Open
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ htmlcov/
refs/
docs/
tests/

# Explicit frontend excludes to keep Docker context small
frontend/node_modules/
**/node_modules/
42 changes: 36 additions & 6 deletions app/core/usage/quota.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
29 changes: 28 additions & 1 deletion app/core/usage/refresh_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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")
Expand Down
69 changes: 69 additions & 0 deletions app/db/alembic/versions/20260319_130000_add_burn_rate_history.py
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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")
30 changes: 30 additions & 0 deletions app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions app/modules/dashboard/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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()
15 changes: 15 additions & 0 deletions app/modules/dashboard/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -32,3 +46,4 @@ class DashboardOverviewResponse(DashboardModel):
trends: MetricsTrends
depletion_primary: DepletionResponse | None = None
depletion_secondary: DepletionResponse | None = None
burn_rate: BurnRateSnapshotResponse | None = None
Loading