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(