diff --git a/app/core/usage/quota.py b/app/core/usage/quota.py index dc76079e..1516fcdf 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 @@ -22,15 +25,29 @@ def apply_usage_quota( if status in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED): return status, used_percent, reset_at + has_credit_override = _has_credit_override( + credits_has=credits_has, + credits_unlimited=credits_unlimited, + credits_balance=credits_balance, + ) + 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 has_credit_override: + 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 if status == AccountStatus.QUOTA_EXCEEDED: - if runtime_reset and runtime_reset > time.time(): + if has_credit_override: + status = AccountStatus.ACTIVE + reset_at = None + elif runtime_reset and runtime_reset > time.time(): reset_at = runtime_reset else: status = AccountStatus.ACTIVE @@ -62,3 +79,18 @@ 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_credit_override( + *, + 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 not None and credits_balance > 0: + return True + return False diff --git a/app/modules/accounts/mappers.py b/app/modules/accounts/mappers.py index 8f1c38c8..cf671f95 100644 --- a/app/modules/accounts/mappers.py +++ b/app/modules/accounts/mappers.py @@ -86,6 +86,12 @@ def _account_to_summary( secondary_used_percent, capacity_secondary, ) + credits_has, credits_unlimited, credits_balance = _extract_credit_status( + effective_primary_usage, + effective_secondary_usage, + primary_usage, + secondary_usage, + ) return AccountSummary( account_id=account.id, email=account.email, @@ -105,6 +111,9 @@ def _account_to_summary( remaining_credits_primary=remaining_credits_primary, capacity_credits_secondary=capacity_secondary, remaining_credits_secondary=remaining_credits_secondary, + credits_has=credits_has, + credits_unlimited=credits_unlimited, + credits_balance=credits_balance, deactivation_reason=account.deactivation_reason, auth=auth_status, ) @@ -182,6 +191,22 @@ def _normalize_used_percent(entry: UsageHistory | None) -> float | None: return entry.used_percent +def _extract_credit_status( + *entries: UsageHistory | None, +) -> tuple[bool | None, bool | None, float | None]: + for entry in entries: + if entry is None: + continue + if ( + entry.credits_has is None + and entry.credits_unlimited is None + and entry.credits_balance is None + ): + continue + return entry.credits_has, entry.credits_unlimited, entry.credits_balance + return None, None, None + + def build_account_usage_trends( buckets: list[UsageTrendBucket], since_epoch: int, diff --git a/app/modules/accounts/schemas.py b/app/modules/accounts/schemas.py index 52bc6abb..a07f123e 100644 --- a/app/modules/accounts/schemas.py +++ b/app/modules/accounts/schemas.py @@ -50,6 +50,9 @@ class AccountSummary(DashboardModel): remaining_credits_primary: float | None = None capacity_credits_secondary: float | None = None remaining_credits_secondary: float | None = None + credits_has: bool | None = None + credits_unlimited: bool | None = None + credits_balance: float | None = None deactivation_reason: str | None = None auth: AccountAuthStatus | None = None diff --git a/app/modules/proxy/load_balancer.py b/app/modules/proxy/load_balancer.py index 7ed83afa..29ed120d 100644 --- a/app/modules/proxy/load_balancer.py +++ b/app/modules/proxy/load_balancer.py @@ -311,6 +311,21 @@ 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_has = ( + primary_entry.credits_has + if primary_entry and primary_entry.credits_has is not None + else (effective_secondary_entry.credits_has if effective_secondary_entry else None) + ) + credits_unlimited = ( + primary_entry.credits_unlimited + if primary_entry and primary_entry.credits_unlimited is not None + else (effective_secondary_entry.credits_unlimited if effective_secondary_entry else None) + ) + credits_balance = ( + primary_entry.credits_balance + if primary_entry and primary_entry.credits_balance is not None + else (effective_secondary_entry.credits_balance if effective_secondary_entry else None) + ) # Use account.reset_at from DB as the authoritative source for runtime reset # and to survive process restarts. @@ -325,6 +340,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/frontend/src/features/accounts/schemas.ts b/frontend/src/features/accounts/schemas.ts index c655405b..8caae3b3 100644 --- a/frontend/src/features/accounts/schemas.ts +++ b/frontend/src/features/accounts/schemas.ts @@ -37,6 +37,13 @@ export const AccountSummarySchema = z.object({ resetAtSecondary: z.string().datetime({ offset: true }).nullable().optional(), windowMinutesPrimary: z.number().nullable().optional(), windowMinutesSecondary: z.number().nullable().optional(), + capacityCreditsPrimary: z.number().nullable().optional(), + remainingCreditsPrimary: z.number().nullable().optional(), + capacityCreditsSecondary: z.number().nullable().optional(), + remainingCreditsSecondary: z.number().nullable().optional(), + creditsHas: z.boolean().nullable().optional(), + creditsUnlimited: z.boolean().nullable().optional(), + creditsBalance: z.number().nullable().optional(), auth: AccountAuthSchema.nullable().optional(), }); diff --git a/frontend/src/features/dashboard/components/account-card.test.tsx b/frontend/src/features/dashboard/components/account-card.test.tsx index bfdd621b..62202117 100644 --- a/frontend/src/features/dashboard/components/account-card.test.tsx +++ b/frontend/src/features/dashboard/components/account-card.test.tsx @@ -29,4 +29,16 @@ describe("AccountCard", () => { expect(screen.queryByText("Primary")).not.toBeInTheDocument(); expect(screen.getByText("Secondary")).toBeInTheDocument(); }); + + it("renders the credits row", () => { + const account = createAccountSummary({ + creditsBalance: 959, + remainingCreditsSecondary: 0, + }); + + render(); + + expect(screen.getByText("Credits:")).toBeInTheDocument(); + expect(screen.getByText("959.00")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/features/dashboard/components/account-card.tsx b/frontend/src/features/dashboard/components/account-card.tsx index 2c8b7641..8b6db853 100644 --- a/frontend/src/features/dashboard/components/account-card.tsx +++ b/frontend/src/features/dashboard/components/account-card.tsx @@ -69,6 +69,14 @@ export function AccountCard({ account, showAccountId = false, onAction }: Accoun const primaryRemaining = account.usage?.primaryRemainingPercent ?? null; const secondaryRemaining = account.usage?.secondaryRemainingPercent ?? null; const weeklyOnly = account.windowMinutesPrimary == null && account.windowMinutesSecondary != null; + const displayCredits = account.creditsBalance ?? ( + weeklyOnly + ? account.remainingCreditsSecondary + : (account.remainingCreditsSecondary ?? account.remainingCreditsPrimary) + ); + const creditsLabel = account.creditsUnlimited ? "Unlimited" : ( + displayCredits === null || displayCredits === undefined ? "-" : displayCredits.toFixed(2) + ); const primaryReset = formatQuotaResetLabel(account.resetAtPrimary ?? null); const secondaryReset = formatQuotaResetLabel(account.resetAtSecondary ?? null); @@ -103,6 +111,13 @@ export function AccountCard({ account, showAccountId = false, onAction }: Accoun +
+ Credits:{" "} + + {creditsLabel} + +
+ {/* Actions */}