Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions app/core/usage/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import re
from typing import Iterable, Mapping

from app.core.plan_types import normalize_account_plan_type
Expand Down Expand Up @@ -38,6 +39,10 @@ def _normalize_window_key(window: str | None) -> str:
return "primary"
if normalized in {"secondary", "7d"}:
return "secondary"
if normalized in {"spark_primary", "spark-primary", "spark 5h", "spark5h"}:
return "spark_primary"
if normalized in {"spark_secondary", "spark-secondary", "spark 7d", "spark7d", "spark_weekly"}:
return "spark_secondary"
return normalized


Expand Down Expand Up @@ -141,18 +146,18 @@ def capacity_for_plan(plan_type: str | None, window: str) -> float | None:
if not normalized:
return None
window_key = _normalize_window_key(window)
if window_key == "primary":
if window_key in {"primary", "spark_primary"}:
return PLAN_CAPACITY_CREDITS_PRIMARY.get(normalized)
if window_key == "secondary":
if window_key in {"secondary", "spark_secondary"}:
return PLAN_CAPACITY_CREDITS_SECONDARY.get(normalized)
return None


def default_window_minutes(window: str) -> int | None:
window_key = _normalize_window_key(window)
if window_key == "primary":
if window_key in {"primary", "spark_primary"}:
return DEFAULT_WINDOW_MINUTES_PRIMARY
if window_key == "secondary":
if window_key in {"secondary", "spark_secondary"}:
return DEFAULT_WINDOW_MINUTES_SECONDARY
return None

Expand All @@ -162,14 +167,26 @@ def parse_usage_summary(
secondary_window: UsageWindowSummary | None,
cost: UsageCostSummary,
metrics: UsageMetricsSummary | None = None,
spark_primary_window: UsageWindowSummary | None = None,
spark_secondary_window: UsageWindowSummary | None = None,
spark_window_label: str | None = None,
) -> UsageSummaryPayload:
primary = normalize_usage_window(primary_window)
secondary = None
if secondary_window is not None:
secondary = normalize_usage_window(secondary_window)
spark_primary = None
if spark_primary_window is not None:
spark_primary = normalize_usage_window(spark_primary_window)
spark_secondary = None
if spark_secondary_window is not None:
spark_secondary = normalize_usage_window(spark_secondary_window)
return UsageSummaryPayload(
primary_window=primary,
secondary_window=secondary,
spark_primary_window=spark_primary,
spark_secondary_window=spark_secondary,
spark_window_label=spark_window_label,
cost=cost,
metrics=metrics,
)
Expand All @@ -179,10 +196,27 @@ async def usage_summary() -> UsageSummaryPayload:
return UsageSummaryPayload(
primary_window=_empty_window(window_minutes=None),
secondary_window=None,
spark_primary_window=None,
spark_secondary_window=None,
spark_window_label=None,
cost=_empty_cost(),
metrics=None,
)


async def usage_history(hours: int) -> UsageHistoryPayload:
return UsageHistoryPayload(window_hours=hours, accounts=[])


def normalize_spark_window_label(value: str | None) -> str:
raw = (value or "").strip()
if not raw:
return "Spark"
normalized = re.sub(r"_window$", "", raw, flags=re.IGNORECASE)
normalized = normalized.replace("-", " ").replace("_", " ")
normalized = re.sub(r"\s+", " ", normalized).strip()
if not normalized:
return "Spark"
if normalized.islower():
return normalized.title()
return normalized
24 changes: 23 additions & 1 deletion app/core/usage/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,32 @@ class UsageWindow(BaseModel):


class RateLimitPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
model_config = ConfigDict(extra="allow")

primary_window: UsageWindow | None = None
secondary_window: UsageWindow | None = None

def spark_windows(self) -> list[tuple[str, UsageWindow]]:
extras = self.model_extra or {}
windows: list[tuple[str, UsageWindow]] = []
for key, value in extras.items():
if "spark" not in key.lower():
continue
try:
window = UsageWindow.model_validate(value)
except Exception:
continue
windows.append((key, window))
windows.sort(key=lambda item: item[0].lower())
return windows


class AdditionalRateLimitPayload(BaseModel):
model_config = ConfigDict(extra="ignore")

limit_name: str | None = None
rate_limit: RateLimitPayload | None = None


class CreditsPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
Expand All @@ -32,4 +53,5 @@ class UsagePayload(BaseModel):

plan_type: str | None = None
rate_limit: RateLimitPayload | None = None
additional_rate_limits: list[AdditionalRateLimitPayload] | None = None
credits: CreditsPayload | None = None
3 changes: 3 additions & 0 deletions app/core/usage/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ class UsageSummaryPayload:
primary_window: UsageWindowSnapshot
secondary_window: UsageWindowSnapshot | None
cost: UsageCostSummary
spark_primary_window: UsageWindowSnapshot | None = None
spark_secondary_window: UsageWindowSnapshot | None = None
spark_window_label: str | None = None
metrics: UsageMetricsSummary | None = None


Expand Down
2 changes: 2 additions & 0 deletions app/db/migrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
add_dashboard_settings,
add_dashboard_settings_totp,
add_request_logs_reasoning_effort,
add_usage_history_window_label,
normalize_account_plan_types,
)

Expand Down Expand Up @@ -45,6 +46,7 @@ class Migration:
Migration("004_add_accounts_chatgpt_account_id", add_accounts_chatgpt_account_id.run),
Migration("005_add_dashboard_settings", add_dashboard_settings.run),
Migration("006_add_dashboard_settings_totp", add_dashboard_settings_totp.run),
Migration("007_add_usage_history_window_label", add_usage_history_window_label.run),
)


Expand Down
20 changes: 20 additions & 0 deletions app/db/migrations/versions/add_usage_history_window_label.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session


def _usage_history_column_state(session: Session) -> tuple[bool, bool]:
inspector = inspect(session.connection())
if not inspector.has_table("usage_history"):
return False, False
columns = {column["name"] for column in inspector.get_columns("usage_history")}
return True, "window_label" in columns


async def run(session: AsyncSession) -> None:
has_table, has_column = await session.run_sync(_usage_history_column_state)
if not has_table or has_column:
return
await session.execute(text("ALTER TABLE usage_history ADD COLUMN window_label VARCHAR"))
1 change: 1 addition & 0 deletions app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class UsageHistory(Base):
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
window: Mapped[str | None] = mapped_column(String, nullable=True)
window_label: Mapped[str | None] = mapped_column(String, nullable=True)
used_percent: Mapped[float] = mapped_column(Float, nullable=False)
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
Expand Down
31 changes: 31 additions & 0 deletions app/modules/accounts/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ def build_account_summaries(
accounts: list[Account],
primary_usage: dict[str, UsageHistory],
secondary_usage: dict[str, UsageHistory],
spark_primary_usage: dict[str, UsageHistory],
spark_secondary_usage: dict[str, UsageHistory],
encryptor: TokenEncryptor,
) -> list[AccountSummary]:
return [
_account_to_summary(
account,
primary_usage.get(account.id),
secondary_usage.get(account.id),
spark_primary_usage.get(account.id),
spark_secondary_usage.get(account.id),
encryptor,
)
for account in accounts
Expand All @@ -33,16 +37,38 @@ def _account_to_summary(
account: Account,
primary_usage: UsageHistory | None,
secondary_usage: UsageHistory | None,
spark_primary_usage: UsageHistory | None,
spark_secondary_usage: UsageHistory | None,
encryptor: TokenEncryptor,
) -> AccountSummary:
plan_type = coerce_account_plan_type(account.plan_type, DEFAULT_PLAN)
auth_status = _build_auth_status(account, encryptor)
primary_used_percent = _normalize_used_percent(primary_usage) or 0.0
secondary_used_percent = _normalize_used_percent(secondary_usage) or 0.0
spark_primary_used_percent = _normalize_used_percent(spark_primary_usage)
spark_secondary_used_percent = _normalize_used_percent(spark_secondary_usage)
primary_remaining_percent = usage_core.remaining_percent_from_used(primary_used_percent) or 0.0
secondary_remaining_percent = usage_core.remaining_percent_from_used(secondary_used_percent) or 0.0
spark_primary_remaining_percent = usage_core.remaining_percent_from_used(spark_primary_used_percent)
spark_secondary_remaining_percent = usage_core.remaining_percent_from_used(spark_secondary_used_percent)
reset_at_primary = from_epoch_seconds(primary_usage.reset_at) if primary_usage is not None else None
reset_at_secondary = from_epoch_seconds(secondary_usage.reset_at) if secondary_usage is not None else None
reset_at_spark_primary = (
from_epoch_seconds(spark_primary_usage.reset_at) if spark_primary_usage is not None else None
)
reset_at_spark_secondary = (
from_epoch_seconds(spark_secondary_usage.reset_at) if spark_secondary_usage is not None else None
)
spark_window_label_raw = (
spark_primary_usage.window_label if spark_primary_usage and spark_primary_usage.window_label else None
)
if spark_window_label_raw is None and spark_secondary_usage and spark_secondary_usage.window_label:
spark_window_label_raw = spark_secondary_usage.window_label
spark_window_label = (
usage_core.normalize_spark_window_label(spark_window_label_raw)
if spark_primary_usage is not None or spark_secondary_usage is not None
else None
)
capacity_primary = usage_core.capacity_for_plan(plan_type, "primary")
capacity_secondary = usage_core.capacity_for_plan(plan_type, "secondary")
remaining_credits_primary = usage_core.remaining_credits_from_percent(
Expand All @@ -62,9 +88,14 @@ def _account_to_summary(
usage=AccountUsage(
primary_remaining_percent=primary_remaining_percent,
secondary_remaining_percent=secondary_remaining_percent,
spark_primary_remaining_percent=spark_primary_remaining_percent,
spark_secondary_remaining_percent=spark_secondary_remaining_percent,
),
reset_at_primary=reset_at_primary,
reset_at_secondary=reset_at_secondary,
reset_at_spark_primary=reset_at_spark_primary,
reset_at_spark_secondary=reset_at_spark_secondary,
spark_window_label=spark_window_label,
last_refresh_at=account.last_refresh,
capacity_credits_primary=capacity_primary,
remaining_credits_primary=remaining_credits_primary,
Expand Down
5 changes: 5 additions & 0 deletions app/modules/accounts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
class AccountUsage(DashboardModel):
primary_remaining_percent: float | None = None
secondary_remaining_percent: float | None = None
spark_primary_remaining_percent: float | None = None
spark_secondary_remaining_percent: float | None = None


class AccountTokenStatus(DashboardModel):
Expand All @@ -33,6 +35,9 @@ class AccountSummary(DashboardModel):
usage: AccountUsage | None = None
reset_at_primary: datetime | None = None
reset_at_secondary: datetime | None = None
reset_at_spark_primary: datetime | None = None
reset_at_spark_secondary: datetime | None = None
spark_window_label: str | None = None
last_refresh_at: datetime | None = None
capacity_credits_primary: float | None = None
remaining_credits_primary: float | None = None
Expand Down
8 changes: 8 additions & 0 deletions app/modules/accounts/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,18 @@ async def list_accounts(self) -> list[AccountSummary]:
return []
primary_usage = await self._usage_repo.latest_by_account(window="primary") if self._usage_repo else {}
secondary_usage = await self._usage_repo.latest_by_account(window="secondary") if self._usage_repo else {}
spark_primary_usage = (
await self._usage_repo.latest_by_account(window="spark_primary") if self._usage_repo else {}
)
spark_secondary_usage = (
await self._usage_repo.latest_by_account(window="spark_secondary") if self._usage_repo else {}
)
return build_account_summaries(
accounts=accounts,
primary_usage=primary_usage,
secondary_usage=secondary_usage,
spark_primary_usage=spark_primary_usage,
spark_secondary_usage=spark_secondary_usage,
encryptor=self._encryptor,
)

Expand Down
2 changes: 2 additions & 0 deletions app/modules/dashboard/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
class DashboardUsageWindows(DashboardModel):
primary: UsageWindowResponse
secondary: UsageWindowResponse | None = None
spark_primary: UsageWindowResponse | None = None
spark_secondary: UsageWindowResponse | None = None


class DashboardOverviewResponse(DashboardModel):
Expand Down
Loading