Merge branch 'feat/rate-limit-tiering' of github.com:Significant-Gravitas/AutoGPT into feat/rate-limit-tiering

This commit is contained in:
Zamil Majdy
2026-03-29 06:42:40 +02:00
11 changed files with 396 additions and 39 deletions

View File

@@ -17,7 +17,7 @@ from backend.copilot.rate_limit import (
reset_user_usage,
set_user_tier,
)
from backend.data.user import get_user_by_email, get_user_email_by_id
from backend.data.user import get_user_by_email, get_user_email_by_id, search_users
logger = logging.getLogger(__name__)
@@ -205,3 +205,28 @@ async def set_user_rate_limit_tier(
raise HTTPException(status_code=500, detail="Failed to set tier") from e
return UserTierResponse(user_id=request.user_id, tier=request.tier)
class UserSearchResult(BaseModel):
user_id: str
user_email: Optional[str] = None
@router.get(
"/rate_limit/search_users",
response_model=list[UserSearchResult],
summary="Search Users by Name or Email",
)
async def admin_search_users(
query: str,
limit: int = 20,
admin_user_id: str = Security(get_user_id),
) -> list[UserSearchResult]:
"""Search users by partial email or name. Admin-only.
Queries the User table directly — returns results even for users
without credit transaction history.
"""
logger.info("Admin %s searching users with query=%r", admin_user_id, query)
results = await search_users(query, limit=min(limit, 50))
return [UserSearchResult(user_id=uid, user_email=email) for uid, email in results]

View File

@@ -324,6 +324,24 @@ def test_set_user_tier_invalid_tier(
assert response.status_code == 422
def test_set_user_tier_invalid_tier_uppercase(
target_user_id: str,
) -> None:
"""Test that setting an unrecognised uppercase tier (e.g. 'INVALID') returns 422.
Regression: ensures Pydantic enum validation rejects values that are not
members of SubscriptionTier, even when they look like valid enum names.
"""
response = client.post(
"/admin/rate_limit/tier",
json={"user_id": target_user_id, "tier": "INVALID"},
)
assert response.status_code == 422
body = response.json()
assert "detail" in body
def test_set_user_tier_user_not_found(
mocker: pytest_mock.MockerFixture,
target_user_id: str,
@@ -376,3 +394,48 @@ def test_tier_endpoints_require_admin_role(mock_jwt_user) -> None:
json={"user_id": "test", "tier": "PRO"},
)
assert response.status_code == 403
# ─── search_users endpoint ──────────────────────────────────────────
def test_search_users_returns_matching_users(
mocker: pytest_mock.MockerFixture,
admin_user_id: str,
) -> None:
"""Partial search should return all matching users from the User table."""
mocker.patch(
_MOCK_MODULE + ".search_users",
new_callable=AsyncMock,
return_value=[
("user-1", "zamil.majdy@gmail.com"),
("user-2", "zamil.majdy@agpt.co"),
],
)
response = client.get("/admin/rate_limit/search_users", params={"query": "zamil"})
assert response.status_code == 200
results = response.json()
assert len(results) == 2
assert results[0]["user_email"] == "zamil.majdy@gmail.com"
assert results[1]["user_email"] == "zamil.majdy@agpt.co"
def test_search_users_empty_results(
mocker: pytest_mock.MockerFixture,
admin_user_id: str,
) -> None:
"""Search with no matches returns empty list."""
mocker.patch(
_MOCK_MODULE + ".search_users",
new_callable=AsyncMock,
return_value=[],
)
response = client.get(
"/admin/rate_limit/search_users", params={"query": "nonexistent"}
)
assert response.status_code == 200
assert response.json() == []

View File

@@ -968,3 +968,87 @@ class TestTierLimitsEnforced:
assert daily == self._BASE_DAILY # 1x multiplier
with pytest.raises(RateLimitExceeded):
await check_rate_limit(_USER, daily, weekly)
@pytest.mark.asyncio
async def test_free_tier_cannot_bypass_pro_limit(self):
"""A FREE-tier user whose usage is within PRO limits but over FREE
limits must still be rejected.
Negative test: ensures the tier multiplier is applied *before* the
rate-limit check, so a lower-tier user cannot 'bypass' limits that
would be acceptable for a higher tier.
"""
free_daily = self._BASE_DAILY * TIER_MULTIPLIERS[SubscriptionTier.FREE]
pro_daily = self._BASE_DAILY * TIER_MULTIPLIERS[SubscriptionTier.PRO]
# Usage above FREE limit but below PRO limit
usage = free_daily + 500_000
assert usage < pro_daily, "test sanity: usage must be under PRO limit"
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(side_effect=[str(usage), "0"])
with (
patch(
"backend.copilot.rate_limit.get_user_tier",
new_callable=AsyncMock,
return_value=SubscriptionTier.FREE,
),
patch(
"backend.util.feature_flag.get_feature_flag_value",
side_effect=self._ld_side_effect(self._BASE_DAILY, self._BASE_WEEKLY),
),
patch(
"backend.copilot.rate_limit.get_redis_async",
return_value=mock_redis,
),
):
daily, weekly, tier = await get_global_rate_limits(
_USER, self._BASE_DAILY, self._BASE_WEEKLY
)
assert tier == SubscriptionTier.FREE
assert daily == free_daily # 1x, not 5x
with pytest.raises(RateLimitExceeded) as exc_info:
await check_rate_limit(_USER, daily, weekly)
assert exc_info.value.window == "daily"
@pytest.mark.asyncio
async def test_tier_change_updates_effective_limits(self):
"""After upgrading from FREE to BUSINESS, the effective limits must
increase accordingly.
Verifies that the tier multiplier is correctly applied after a tier
change, and that usage that was over the FREE limit is within the new
BUSINESS limit.
"""
free_daily = self._BASE_DAILY * TIER_MULTIPLIERS[SubscriptionTier.FREE]
biz_daily = self._BASE_DAILY * TIER_MULTIPLIERS[SubscriptionTier.BUSINESS]
# Usage above FREE limit but below BUSINESS limit
usage = free_daily + 500_000
assert usage < biz_daily, "test sanity: usage must be under BUSINESS limit"
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(side_effect=[str(usage), "0"])
# Simulate the user having been upgraded to BUSINESS
with (
patch(
"backend.copilot.rate_limit.get_user_tier",
new_callable=AsyncMock,
return_value=SubscriptionTier.BUSINESS,
),
patch(
"backend.util.feature_flag.get_feature_flag_value",
side_effect=self._ld_side_effect(self._BASE_DAILY, self._BASE_WEEKLY),
),
patch(
"backend.copilot.rate_limit.get_redis_async",
return_value=mock_redis,
),
):
daily, weekly, tier = await get_global_rate_limits(
_USER, self._BASE_DAILY, self._BASE_WEEKLY
)
assert tier == SubscriptionTier.BUSINESS
assert daily == biz_daily # 20x
# Should NOT raise — usage is within the BUSINESS tier allowance
await check_rate_limit(_USER, daily, weekly)

View File

@@ -79,6 +79,25 @@ async def get_user_by_email(email: str) -> Optional[User]:
raise DatabaseError(f"Failed to get user by email {email}: {e}") from e
async def search_users(query: str, limit: int = 20) -> list[tuple[str, str | None]]:
"""Search users by partial email or name.
Returns a list of ``(user_id, email)`` tuples, up to *limit* results.
Searches the User table directly — no dependency on credit history.
"""
users = await prisma.user.find_many(
where={
"OR": [
{"email": {"contains": query, "mode": "insensitive"}},
{"name": {"contains": query, "mode": "insensitive"}},
],
},
take=limit,
order={"email": "asc"},
)
return [(u.id, u.email) for u in users]
async def update_user_email(user_id: str, email: str):
try:
# Get old email first for cache invalidation

View File

@@ -3,18 +3,47 @@
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import type { UserRateLimitResponse } from "@/app/api/__generated__/models/userRateLimitResponse";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { UsageBar } from "../../components/UsageBar";
const TIERS = ["FREE", "PRO", "BUSINESS", "ENTERPRISE"] as const;
type Tier = (typeof TIERS)[number];
const TIER_MULTIPLIERS: Record<Tier, string> = {
FREE: "1x base limits",
PRO: "5x base limits",
BUSINESS: "20x base limits",
ENTERPRISE: "60x base limits",
};
const TIER_COLORS: Record<Tier, string> = {
FREE: "bg-gray-100 text-gray-700",
PRO: "bg-blue-100 text-blue-700",
BUSINESS: "bg-purple-100 text-purple-700",
ENTERPRISE: "bg-amber-100 text-amber-700",
};
interface Props {
data: UserRateLimitResponse;
onReset: (resetWeekly: boolean) => Promise<void>;
onTierChange?: (newTier: string) => Promise<void>;
/** Override the outer container classes (default: bordered card). */
className?: string;
}
export function RateLimitDisplay({ data, onReset, className }: Props) {
export function RateLimitDisplay({
data,
onReset,
onTierChange,
className,
}: Props) {
const [isResetting, setIsResetting] = useState(false);
const [resetWeekly, setResetWeekly] = useState(false);
const [isChangingTier, setIsChangingTier] = useState(false);
const { toast } = useToast();
const currentTier =
((data as unknown as Record<string, unknown>).tier as Tier) ?? "PRO";
async function handleReset() {
const msg = resetWeekly
@@ -30,19 +59,76 @@ export function RateLimitDisplay({ data, onReset, className }: Props) {
}
}
async function handleTierChange(newTier: string) {
if (newTier === currentTier || !onTierChange) return;
if (
!window.confirm(
`Change tier from ${currentTier} to ${newTier}? This will change the user's rate limits.`,
)
)
return;
setIsChangingTier(true);
try {
await onTierChange(newTier);
toast({
title: "Tier updated",
description: `Changed to ${newTier} (${TIER_MULTIPLIERS[newTier as Tier]}).`,
});
} catch {
toast({
title: "Error",
description: "Failed to update tier.",
variant: "destructive",
});
} finally {
setIsChangingTier(false);
}
}
const nothingToReset = resetWeekly
? data.daily_tokens_used === 0 && data.weekly_tokens_used === 0
: data.daily_tokens_used === 0;
return (
<div className={className ?? "rounded-md border bg-white p-6"}>
<h2 className="mb-1 text-lg font-semibold">
Rate Limits for {data.user_email ?? data.user_id}
</h2>
{data.user_email && (
<p className="mb-4 text-xs text-gray-500">User ID: {data.user_id}</p>
)}
{!data.user_email && <div className="mb-4" />}
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="mb-1 text-lg font-semibold">
Rate Limits for {data.user_email ?? data.user_id}
</h2>
{data.user_email && (
<p className="text-xs text-gray-500">User ID: {data.user_id}</p>
)}
</div>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${TIER_COLORS[currentTier] ?? "bg-gray-100 text-gray-700"}`}
>
{currentTier}
</span>
</div>
<div className="mb-4 flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">
Subscription Tier
</label>
<select
aria-label="Subscription tier"
value={currentTier}
onChange={(e) => handleTierChange(e.target.value)}
className="rounded-md border bg-white px-3 py-1.5 text-sm"
disabled={isChangingTier || !onTierChange}
>
{TIERS.map((tier) => (
<option key={tier} value={tier}>
{tier} {TIER_MULTIPLIERS[tier]}
</option>
))}
</select>
{isChangingTier && (
<span className="text-xs text-gray-500">Updating...</span>
)}
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">

View File

@@ -14,6 +14,7 @@ export function RateLimitManager() {
handleSearch,
handleSelectUser,
handleReset,
handleTierChange,
} = useRateLimitManager();
return (
@@ -74,7 +75,11 @@ export function RateLimitManager() {
)}
{rateLimitData && (
<RateLimitDisplay data={rateLimitData} onReset={handleReset} />
<RateLimitDisplay
data={rateLimitData}
onReset={handleReset}
onTierChange={handleTierChange}
/>
)}
</div>
);

View File

@@ -5,7 +5,6 @@ import { useToast } from "@/components/molecules/Toast/use-toast";
import type { UserRateLimitResponse } from "@/app/api/__generated__/models/userRateLimitResponse";
import {
getV2GetUserRateLimit,
getV2GetAllUsersHistory,
postV2ResetUserRateLimitUsage,
} from "@/app/api/__generated__/endpoints/admin/admin";
@@ -77,7 +76,8 @@ export function useRateLimitManager() {
}
}
/** Fuzzy name/email search via the spending-history endpoint. */
/** Search users by partial name/email via the User table. */
/** Search users by partial name/email via the User table. */
async function handleFuzzySearch(trimmed: string) {
setIsSearching(true);
setSearchResults([]);
@@ -85,38 +85,17 @@ export function useRateLimitManager() {
setRateLimitData(null);
try {
const response = await getV2GetAllUsersHistory({
search: trimmed,
page: 1,
page_size: 50,
});
if (response.status !== 200) {
const response = await fetch(
`/api/proxy/api/copilot/admin/rate_limit/search_users?query=${encodeURIComponent(trimmed)}&limit=20`,
);
if (!response.ok) {
throw new Error("Failed to search users");
}
// Deduplicate by user_id to get unique users
const seen = new Set<string>();
const users: UserOption[] = [];
for (const tx of response.data.history) {
if (!seen.has(tx.user_id)) {
seen.add(tx.user_id);
users.push({
user_id: tx.user_id,
user_email: String(tx.user_email ?? tx.user_id),
});
}
}
const users: UserOption[] = await response.json();
if (users.length === 0) {
toast({
title: "No results",
description: "No users found matching your search.",
});
toast({ title: "No results", description: "No users found." });
}
// Always show the result list so the user explicitly picks a match.
// The history endpoint paginates transactions, not users, so a single
// page may not be authoritative -- avoid auto-selecting.
setSearchResults(users);
} catch (error) {
console.error("Error searching users:", error);
@@ -199,6 +178,41 @@ export function useRateLimitManager() {
}
}
async function handleTierChange(newTier: string) {
if (!rateLimitData) return;
const response = await fetch(
"/api/proxy/api/copilot/admin/rate_limit/tier",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: rateLimitData.user_id,
tier: newTier,
}),
},
);
if (!response.ok) {
throw new Error("Failed to update tier");
}
// Re-fetch rate limit data to reflect new tier limits.
// Use a direct fetch so errors propagate to the caller's catch block
// (fetchRateLimit swallows errors internally with its own toast).
try {
const refreshResponse = await getV2GetUserRateLimit({
user_id: rateLimitData.user_id,
});
if (refreshResponse.status === 200) {
setRateLimitData(refreshResponse.data);
}
} catch {
// Tier was changed server-side; UI will be stale but not incorrect.
// The caller's success toast is still valid — the tier change worked.
}
}
return {
isSearching,
isLoadingRateLimit,
@@ -208,5 +222,6 @@ export function useRateLimitManager() {
handleSearch,
handleSelectUser,
handleReset,
handleTierChange,
};
}

View File

@@ -1532,6 +1532,54 @@
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/copilot/admin/rate_limit/search_users": {
"get": {
"tags": ["v2", "admin", "copilot", "admin"],
"summary": "Search Users by Name or Email",
"description": "Search users by partial email or name. Admin-only.\n\nQueries the User table directly — returns results even for users\nwithout credit transaction history.",
"operationId": "getV2Search users by name or email",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "query",
"in": "query",
"required": true,
"schema": { "type": "string", "title": "Query" }
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": { "type": "integer", "default": 20, "title": "Limit" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/UserSearchResult" },
"title": "Response Getv2Search Users By Name Or Email"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/copilot/admin/rate_limit/tier": {
"get": {
"tags": ["v2", "admin", "copilot", "admin"],
@@ -14860,6 +14908,18 @@
"title": "UserReadiness",
"description": "User readiness status."
},
"UserSearchResult": {
"properties": {
"user_id": { "type": "string", "title": "User Id" },
"user_email": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Email"
}
},
"type": "object",
"required": ["user_id"],
"title": "UserSearchResult"
},
"UserTierResponse": {
"properties": {
"user_id": { "type": "string", "title": "User Id" },

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB