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
44 changes: 38 additions & 6 deletions app/core/usage/quota.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,39 @@ 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

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
Expand Down Expand Up @@ -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
25 changes: 25 additions & 0 deletions app/modules/accounts/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions app/modules/accounts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions app/modules/proxy/load_balancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/features/accounts/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down
12 changes: 12 additions & 0 deletions frontend/src/features/dashboard/components/account-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AccountCard account={account} />);

expect(screen.getByText("Credits:")).toBeInTheDocument();
expect(screen.getByText("959.00")).toBeInTheDocument();
});
});
15 changes: 15 additions & 0 deletions frontend/src/features/dashboard/components/account-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -103,6 +111,13 @@ export function AccountCard({ account, showAccountId = false, onAction }: Accoun
<QuotaBar label="Secondary" percent={secondaryRemaining} resetLabel={secondaryReset} />
</div>

<div className="mt-3 text-xs text-muted-foreground">
Credits:{" "}
<span className="font-medium tabular-nums text-foreground">
{creditsLabel}
</span>
</div>

{/* Actions */}
<div className="mt-3 flex items-center gap-1.5 border-t pt-3">
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ export function RecentRequestsTable({
<TableBody>
{requests.map((request) => {
const time = formatTimeLong(request.requestedAt);
const accountLabel = accountLabelMap.get(request.accountId) ?? request.accountId;
const accountLabel =
(request.accountId ? accountLabelMap.get(request.accountId) : null) ??
request.accountId ??
"Unassigned";
const errorMessage = request.errorMessage || request.errorCode || "-";
const hasLongError = errorMessage !== "-" && errorMessage.length > 72;

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/features/dashboard/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export const DashboardOverviewSchema = z.object({

export const RequestLogSchema = z.object({
requestedAt: z.string().datetime({ offset: true }),
accountId: z.string(),
accountId: z.string().nullable(),
apiKeyName: z.string().nullable(),
requestId: z.string(),
model: z.string(),
status: z.string(),
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/test/mocks/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ export function createAccountSummary(overrides: Partial<AccountSummary> = {}): A
resetAtSecondary: offsetIso(24 * 60),
windowMinutesPrimary: 300,
windowMinutesSecondary: 10_080,
capacityCreditsPrimary: 225,
remainingCreditsPrimary: 184.5,
capacityCreditsSecondary: 7560,
remainingCreditsSecondary: 5065.2,
creditsHas: true,
creditsUnlimited: false,
creditsBalance: 932,
auth: {
access: { expiresAt: offsetIso(30), state: null },
refresh: { state: "stored" },
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/test/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ function filterRequestLogs(url: URL, options?: { includeStatuses?: boolean }): R
const until = parseDateValue(url.searchParams.get("until"));

return state.requestLogs.filter((entry) => {
if (accountIds.size > 0 && !accountIds.has(entry.accountId)) {
if (accountIds.size > 0 && (!entry.accountId || !accountIds.has(entry.accountId))) {
return false;
}

Expand Down Expand Up @@ -183,7 +183,7 @@ function filterRequestLogs(url: URL, options?: { includeStatuses?: boolean }): R
}

function requestLogOptionsFromEntries(entries: RequestLogEntry[]) {
const accountIds = [...new Set(entries.map((entry) => entry.accountId))].sort();
const accountIds = [...new Set(entries.map((entry) => entry.accountId).filter((entry): entry is string => Boolean(entry)))].sort();

const modelMap = new Map<string, { model: string; reasoningEffort: string | null }>();
for (const entry of entries) {
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/test_load_balancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,43 @@ def test_apply_usage_quota_resets_to_active_if_runtime_reset_expired(monkeypatch
assert status == AccountStatus.ACTIVE
assert used_percent == 50.0
assert reset_at is None


def test_apply_usage_quota_allows_secondary_100_when_credits_exist():
status, used_percent, reset_at = apply_usage_quota(
status=AccountStatus.ACTIVE,
primary_used=11.0,
primary_reset=None,
primary_window_minutes=None,
runtime_reset=None,
secondary_used=100.0,
secondary_reset=1_700_010_000,
credits_has=True,
credits_unlimited=False,
credits_balance=959.0,
)
assert status == AccountStatus.ACTIVE
assert used_percent == 11.0
assert reset_at is None


def test_apply_usage_quota_clears_quota_exceeded_when_credits_balance_positive(monkeypatch):
now = 1_700_000_000.0
future = now + 3600.0
monkeypatch.setattr("app.core.usage.quota.time.time", lambda: now)

status, used_percent, reset_at = apply_usage_quota(
status=AccountStatus.QUOTA_EXCEEDED,
primary_used=20.0,
primary_reset=None,
primary_window_minutes=None,
runtime_reset=future,
secondary_used=100.0,
secondary_reset=1_700_010_000,
credits_has=None,
credits_unlimited=None,
credits_balance=1.0,
)
assert status == AccountStatus.ACTIVE
assert used_percent == 20.0
assert reset_at is None