merge: resolve conflicts with dev, keep tier changes

Merge origin/dev into feat/rate-limit-tiering. Conflicts arose from
the admin-routes refactor (resolved_id rename, _patch_rate_limit_deps
helper) colliding with our 3-tuple get_global_rate_limits and tier
field additions. Resolution keeps our SubscriptionTier enum, 3-tuple
returns, and tier fields while adopting the incoming resolved_id
variable and DRY test helper. Snapshots now include both tier and
user_email fields.
This commit is contained in:
Zamil Majdy
2026-03-27 13:12:38 +07:00
14 changed files with 753 additions and 212 deletions

View File

@@ -1,6 +1,7 @@
"""Admin endpoints for checking and resetting user CoPilot rate limit usage."""
import logging
from typing import Optional
import prisma.errors
from autogpt_libs.auth import get_user_id, requires_admin_user
@@ -16,6 +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
logger = logging.getLogger(__name__)
@@ -30,6 +32,7 @@ router = APIRouter(
class UserRateLimitResponse(BaseModel):
user_id: str
user_email: Optional[str] = None
daily_token_limit: int
weekly_token_limit: int
daily_tokens_used: int
@@ -47,25 +50,65 @@ class SetUserTierRequest(BaseModel):
tier: SubscriptionTier
async def _resolve_user_id(
user_id: Optional[str], email: Optional[str]
) -> tuple[str, Optional[str]]:
"""Resolve a user_id and email from the provided parameters.
Returns (user_id, email). Accepts either user_id or email; at least one
must be provided. When both are provided, ``email`` takes precedence.
"""
if email:
user = await get_user_by_email(email)
if not user:
raise HTTPException(
status_code=404, detail="No user found with the provided email."
)
return user.id, email
if not user_id:
raise HTTPException(
status_code=400,
detail="Either user_id or email query parameter is required.",
)
# We have a user_id; try to look up their email for display purposes.
# This is non-critical -- a failure should not block the response.
try:
resolved_email = await get_user_email_by_id(user_id)
except Exception:
logger.warning("Failed to resolve email for user %s", user_id, exc_info=True)
resolved_email = None
return user_id, resolved_email
@router.get(
"/rate_limit",
response_model=UserRateLimitResponse,
summary="Get User Rate Limit",
)
async def get_user_rate_limit(
user_id: str,
user_id: Optional[str] = None,
email: Optional[str] = None,
admin_user_id: str = Security(get_user_id),
) -> UserRateLimitResponse:
"""Get a user's current usage and effective rate limits. Admin-only."""
logger.info(f"Admin {admin_user_id} checking rate limit for user {user_id}")
"""Get a user's current usage and effective rate limits. Admin-only.
Accepts either ``user_id`` or ``email`` as a query parameter.
When ``email`` is provided the user is looked up by email first.
"""
resolved_id, resolved_email = await _resolve_user_id(user_id, email)
logger.info("Admin %s checking rate limit for user %s", admin_user_id, resolved_id)
daily_limit, weekly_limit, tier = await get_global_rate_limits(
user_id, config.daily_token_limit, config.weekly_token_limit
resolved_id, config.daily_token_limit, config.weekly_token_limit
)
usage = await get_usage_status(user_id, daily_limit, weekly_limit, tier=tier)
usage = await get_usage_status(resolved_id, daily_limit, weekly_limit, tier=tier)
return UserRateLimitResponse(
user_id=user_id,
user_id=resolved_id,
user_email=resolved_email,
daily_token_limit=daily_limit,
weekly_token_limit=weekly_limit,
daily_tokens_used=usage.daily.used,
@@ -86,8 +129,10 @@ async def reset_user_rate_limit(
) -> UserRateLimitResponse:
"""Reset a user's daily usage counter (and optionally weekly). Admin-only."""
logger.info(
f"Admin {admin_user_id} resetting rate limit for user {user_id} "
f"(reset_weekly={reset_weekly})"
"Admin %s resetting rate limit for user %s (reset_weekly=%s)",
admin_user_id,
user_id,
reset_weekly,
)
try:
@@ -101,8 +146,15 @@ async def reset_user_rate_limit(
)
usage = await get_usage_status(user_id, daily_limit, weekly_limit, tier=tier)
try:
resolved_email = await get_user_email_by_id(user_id)
except Exception:
logger.warning("Failed to resolve email for user %s", user_id, exc_info=True)
resolved_email = None
return UserRateLimitResponse(
user_id=user_id,
user_email=resolved_email,
daily_token_limit=daily_limit,
weekly_token_limit=weekly_limit,
daily_tokens_used=usage.daily.used,

View File

@@ -1,4 +1,5 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import fastapi
@@ -19,6 +20,8 @@ client = fastapi.testclient.TestClient(app)
_MOCK_MODULE = "backend.api.features.admin.rate_limit_admin_routes"
_TARGET_EMAIL = "target@example.com"
@pytest.fixture(autouse=True)
def setup_app_admin_auth(mock_jwt_admin):
@@ -44,12 +47,13 @@ def _mock_usage_status(
)
def test_get_rate_limit(
def _patch_rate_limit_deps(
mocker: pytest_mock.MockerFixture,
configured_snapshot: Snapshot,
target_user_id: str,
) -> None:
"""Test getting rate limit and usage for a user."""
daily_used: int = 500_000,
weekly_used: int = 3_000_000,
):
"""Patch the common rate-limit + user-lookup dependencies."""
mocker.patch(
f"{_MOCK_MODULE}.get_global_rate_limits",
new_callable=AsyncMock,
@@ -58,14 +62,29 @@ def test_get_rate_limit(
mocker.patch(
f"{_MOCK_MODULE}.get_usage_status",
new_callable=AsyncMock,
return_value=_mock_usage_status(),
return_value=_mock_usage_status(daily_used=daily_used, weekly_used=weekly_used),
)
mocker.patch(
f"{_MOCK_MODULE}.get_user_email_by_id",
new_callable=AsyncMock,
return_value=_TARGET_EMAIL,
)
def test_get_rate_limit(
mocker: pytest_mock.MockerFixture,
configured_snapshot: Snapshot,
target_user_id: str,
) -> None:
"""Test getting rate limit and usage for a user."""
_patch_rate_limit_deps(mocker, target_user_id)
response = client.get("/admin/rate_limit", params={"user_id": target_user_id})
assert response.status_code == 200
data = response.json()
assert data["user_id"] == target_user_id
assert data["user_email"] == _TARGET_EMAIL
assert data["daily_token_limit"] == 2_500_000
assert data["weekly_token_limit"] == 12_500_000
assert data["daily_tokens_used"] == 500_000
@@ -78,6 +97,50 @@ def test_get_rate_limit(
)
def test_get_rate_limit_by_email(
mocker: pytest_mock.MockerFixture,
target_user_id: str,
) -> None:
"""Test looking up rate limits via email instead of user_id."""
_patch_rate_limit_deps(mocker, target_user_id)
mock_user = SimpleNamespace(id=target_user_id, email=_TARGET_EMAIL)
mocker.patch(
f"{_MOCK_MODULE}.get_user_by_email",
new_callable=AsyncMock,
return_value=mock_user,
)
response = client.get("/admin/rate_limit", params={"email": _TARGET_EMAIL})
assert response.status_code == 200
data = response.json()
assert data["user_id"] == target_user_id
assert data["user_email"] == _TARGET_EMAIL
assert data["daily_token_limit"] == 2_500_000
def test_get_rate_limit_by_email_not_found(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Test that looking up a non-existent email returns 404."""
mocker.patch(
f"{_MOCK_MODULE}.get_user_by_email",
new_callable=AsyncMock,
return_value=None,
)
response = client.get("/admin/rate_limit", params={"email": "nobody@example.com"})
assert response.status_code == 404
def test_get_rate_limit_no_params() -> None:
"""Test that omitting both user_id and email returns 400."""
response = client.get("/admin/rate_limit")
assert response.status_code == 400
def test_reset_user_usage_daily_only(
mocker: pytest_mock.MockerFixture,
configured_snapshot: Snapshot,
@@ -88,16 +151,7 @@ def test_reset_user_usage_daily_only(
f"{_MOCK_MODULE}.reset_user_usage",
new_callable=AsyncMock,
)
mocker.patch(
f"{_MOCK_MODULE}.get_global_rate_limits",
new_callable=AsyncMock,
return_value=(2_500_000, 12_500_000, SubscriptionTier.FREE),
)
mocker.patch(
f"{_MOCK_MODULE}.get_usage_status",
new_callable=AsyncMock,
return_value=_mock_usage_status(daily_used=0, weekly_used=3_000_000),
)
_patch_rate_limit_deps(mocker, target_user_id, daily_used=0, weekly_used=3_000_000)
response = client.post(
"/admin/rate_limit/reset",
@@ -129,16 +183,7 @@ def test_reset_user_usage_daily_and_weekly(
f"{_MOCK_MODULE}.reset_user_usage",
new_callable=AsyncMock,
)
mocker.patch(
f"{_MOCK_MODULE}.get_global_rate_limits",
new_callable=AsyncMock,
return_value=(2_500_000, 12_500_000, SubscriptionTier.FREE),
)
mocker.patch(
f"{_MOCK_MODULE}.get_usage_status",
new_callable=AsyncMock,
return_value=_mock_usage_status(daily_used=0, weekly_used=0),
)
_patch_rate_limit_deps(mocker, target_user_id, daily_used=0, weekly_used=0)
response = client.post(
"/admin/rate_limit/reset",
@@ -178,6 +223,35 @@ def test_reset_user_usage_redis_failure(
assert response.status_code == 500
def test_get_rate_limit_email_lookup_failure(
mocker: pytest_mock.MockerFixture,
target_user_id: str,
) -> None:
"""Test that failing to resolve a user email degrades gracefully."""
mocker.patch(
f"{_MOCK_MODULE}.get_global_rate_limits",
new_callable=AsyncMock,
return_value=(2_500_000, 12_500_000, SubscriptionTier.FREE),
)
mocker.patch(
f"{_MOCK_MODULE}.get_usage_status",
new_callable=AsyncMock,
return_value=_mock_usage_status(),
)
mocker.patch(
f"{_MOCK_MODULE}.get_user_email_by_id",
new_callable=AsyncMock,
side_effect=Exception("DB connection lost"),
)
response = client.get("/admin/rate_limit", params={"user_id": target_user_id})
assert response.status_code == 200
data = response.json()
assert data["user_id"] == target_user_id
assert data["user_email"] is None
def test_admin_endpoints_require_admin_role(mock_jwt_user) -> None:
"""Test that rate limit admin endpoints require admin role."""
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]

View File

@@ -2,6 +2,7 @@
"daily_token_limit": 2500000,
"daily_tokens_used": 500000,
"tier": "FREE",
"user_email": "target@example.com",
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
"weekly_token_limit": 12500000,
"weekly_tokens_used": 3000000

View File

@@ -2,6 +2,7 @@
"daily_token_limit": 2500000,
"daily_tokens_used": 0,
"tier": "FREE",
"user_email": "target@example.com",
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
"weekly_token_limit": 12500000,
"weekly_tokens_used": 0

View File

@@ -2,6 +2,7 @@
"daily_token_limit": 2500000,
"daily_tokens_used": 0,
"tier": "FREE",
"user_email": "target@example.com",
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
"weekly_token_limit": 12500000,
"weekly_tokens_used": 3000000

View File

@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/__legacy__/ui/input";
import { Button } from "@/components/atoms/Button/Button";
import { MagnifyingGlass } from "@phosphor-icons/react";
export interface AdminUserSearchProps {
/** Current search query value (controlled). Falls back to internal state if omitted. */
value?: string;
/** Called when the input text changes */
onChange?: (value: string) => void;
/** Called when the user presses Enter or clicks the search button */
onSearch: (query: string) => void;
/** Placeholder text for the input */
placeholder?: string;
/** Disables the input and button while a search is in progress */
isLoading?: boolean;
}
/**
* Shared admin user search input.
* Supports searching users by name, email, or partial/fuzzy text.
* Can be used as controlled (value + onChange) or uncontrolled (internal state).
*/
export function AdminUserSearch({
value: controlledValue,
onChange,
onSearch,
placeholder = "Search users by Name or Email...",
isLoading = false,
}: AdminUserSearchProps) {
const [internalValue, setInternalValue] = useState("");
const isControlled = controlledValue !== undefined;
const currentValue = isControlled ? controlledValue : internalValue;
function handleChange(newValue: string) {
if (isControlled) {
onChange?.(newValue);
} else {
setInternalValue(newValue);
}
}
function handleSearch() {
onSearch(currentValue.trim());
}
return (
<div className="flex w-full items-center gap-2">
<Input
placeholder={placeholder}
aria-label={placeholder}
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
disabled={isLoading}
/>
<Button
variant="outline"
size="small"
onClick={handleSearch}
disabled={isLoading || !currentValue.trim()}
loading={isLoading}
>
{isLoading ? "Searching..." : <MagnifyingGlass size={16} />}
</Button>
</div>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
export function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}K`;
return tokens.toString();
}
export function UsageBar({ used, limit }: { used: number; limit: number }) {
if (limit === 0) {
return <span className="text-sm text-gray-500">Unlimited</span>;
}
const pct = Math.min(Math.max(0, (used / limit) * 100), 100);
const color =
pct >= 90 ? "bg-red-500" : pct >= 70 ? "bg-yellow-500" : "bg-green-500";
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>{formatTokens(used)} used</span>
<span>{formatTokens(limit)} limit</span>
</div>
<div className="h-2 w-full rounded-full bg-gray-200">
<div
className={`h-2 rounded-full ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-right text-xs text-gray-500">
{pct.toFixed(1)}% used
</div>
</div>
);
}

View File

@@ -3,46 +3,16 @@
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import type { UserRateLimitResponse } from "@/app/api/__generated__/models/userRateLimitResponse";
function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}K`;
return tokens.toString();
}
function UsageBar({ used, limit }: { used: number; limit: number }) {
if (limit === 0) {
return <span className="text-sm text-gray-500">Unlimited</span>;
}
const pct = Math.min((used / limit) * 100, 100);
const color =
pct >= 90 ? "bg-red-500" : pct >= 70 ? "bg-yellow-500" : "bg-green-500";
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>{formatTokens(used)} used</span>
<span>{formatTokens(limit)} limit</span>
</div>
<div className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
className={`h-2 rounded-full ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-right text-xs text-gray-500">
{pct.toFixed(1)}% used
</div>
</div>
);
}
import { UsageBar } from "../../components/UsageBar";
interface Props {
data: UserRateLimitResponse;
onReset: (resetWeekly: boolean) => Promise<void>;
/** Override the outer container classes (default: bordered card). */
className?: string;
}
export function RateLimitDisplay({ data, onReset }: Props) {
export function RateLimitDisplay({ data, onReset, className }: Props) {
const [isResetting, setIsResetting] = useState(false);
const [resetWeekly, setResetWeekly] = useState(false);
@@ -65,25 +35,25 @@ export function RateLimitDisplay({ data, onReset }: Props) {
: data.daily_tokens_used === 0;
return (
<div className="rounded-md border bg-white p-6 dark:bg-gray-900">
<h2 className="mb-4 text-lg font-semibold">
Rate Limits for {data.user_id}
<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="grid grid-cols-2 gap-6">
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Daily Usage
</h3>
<h3 className="text-sm font-medium text-gray-700">Daily Usage</h3>
<UsageBar
used={data.daily_tokens_used}
limit={data.daily_token_limit}
/>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Weekly Usage
</h3>
<h3 className="text-sm font-medium text-gray-700">Weekly Usage</h3>
<UsageBar
used={data.weekly_tokens_used}
limit={data.weekly_token_limit}
@@ -93,9 +63,10 @@ export function RateLimitDisplay({ data, onReset }: Props) {
<div className="mt-6 flex items-center gap-3 border-t pt-4">
<select
aria-label="Reset scope"
value={resetWeekly ? "both" : "daily"}
onChange={(e) => setResetWeekly(e.target.value === "both")}
className="rounded-md border bg-white px-3 py-1.5 text-sm dark:bg-gray-800 dark:text-gray-200"
className="rounded-md border bg-white px-3 py-1.5 text-sm"
disabled={isResetting}
>
<option value="daily">Reset daily only</option>

View File

@@ -1,101 +1,78 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/__legacy__/ui/input";
import { Label } from "@/components/__legacy__/ui/label";
import { MagnifyingGlass } from "@phosphor-icons/react";
import { useToast } from "@/components/molecules/Toast/use-toast";
import type { UserRateLimitResponse } from "@/app/api/__generated__/models/userRateLimitResponse";
import {
getV2GetUserRateLimit,
postV2ResetUserRateLimitUsage,
} from "@/app/api/__generated__/endpoints/admin/admin";
import { AdminUserSearch } from "../../components/AdminUserSearch";
import { RateLimitDisplay } from "./RateLimitDisplay";
import { useRateLimitManager } from "./useRateLimitManager";
export function RateLimitManager() {
const { toast } = useToast();
const [userIdInput, setUserIdInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [rateLimitData, setRateLimitData] =
useState<UserRateLimitResponse | null>(null);
async function handleLookup() {
const trimmed = userIdInput.trim();
if (!trimmed) return;
setIsLoading(true);
try {
const response = await getV2GetUserRateLimit({ user_id: trimmed });
if (response.status !== 200) {
throw new Error("Failed to fetch rate limit");
}
setRateLimitData(response.data);
} catch (error) {
console.error("Error fetching rate limit:", error);
toast({
title: "Error",
description: "Failed to fetch user rate limit. Check the user ID.",
variant: "destructive",
});
setRateLimitData(null);
} finally {
setIsLoading(false);
}
}
async function handleReset(resetWeekly: boolean) {
if (!rateLimitData) return;
try {
const response = await postV2ResetUserRateLimitUsage({
user_id: rateLimitData.user_id,
reset_weekly: resetWeekly,
});
if (response.status !== 200) {
throw new Error("Failed to reset usage");
}
setRateLimitData(response.data);
toast({
title: "Success",
description: resetWeekly
? "Daily and weekly usage reset to zero."
: "Daily usage reset to zero.",
});
} catch (error) {
console.error("Error resetting rate limit:", error);
toast({
title: "Error",
description: "Failed to reset rate limit usage.",
variant: "destructive",
});
}
}
const {
isSearching,
isLoadingRateLimit,
searchResults,
selectedUser,
rateLimitData,
handleSearch,
handleSelectUser,
handleReset,
} = useRateLimitManager();
return (
<div className="space-y-6">
<div className="rounded-md border bg-white p-6 dark:bg-gray-900">
<Label htmlFor="userId" className="mb-2 block text-sm font-medium">
User ID
</Label>
<div className="flex items-center gap-2">
<Input
id="userId"
placeholder="Enter user ID to look up rate limits..."
value={userIdInput}
onChange={(e) => setUserIdInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLookup()}
/>
<Button
variant="outline"
onClick={handleLookup}
disabled={isLoading || !userIdInput.trim()}
>
{isLoading ? "Loading..." : <MagnifyingGlass size={16} />}
</Button>
</div>
<div className="rounded-md border bg-white p-6">
<label className="mb-2 block text-sm font-medium">Search User</label>
<AdminUserSearch
onSearch={handleSearch}
placeholder="Search by name, email, or user ID..."
isLoading={isSearching}
/>
<p className="mt-1.5 text-xs text-gray-500">
Exact email or user ID does a direct lookup. Partial text searches
user history.
</p>
</div>
{/* User selection list -- always require explicit selection */}
{searchResults.length >= 1 && !selectedUser && (
<div className="rounded-md border bg-white p-4">
<h3 className="mb-2 text-sm font-medium text-gray-700">
Select a user ({searchResults.length}{" "}
{searchResults.length === 1 ? "result" : "results"})
</h3>
<ul className="divide-y">
{searchResults.map((user) => (
<li key={user.user_id}>
<button
className="w-full px-2 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => handleSelectUser(user)}
>
<span className="font-medium">{user.user_email}</span>
<span className="ml-2 text-xs text-gray-500">
{user.user_id}
</span>
</button>
</li>
))}
</ul>
</div>
)}
{/* Show selected user */}
{selectedUser && searchResults.length >= 1 && (
<div className="rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-sm">
Selected:{" "}
<span className="font-medium">{selectedUser.user_email}</span>
<span className="ml-2 text-xs text-gray-500">
{selectedUser.user_id}
</span>
</div>
)}
{isLoadingRateLimit && (
<div className="py-4 text-center text-sm text-gray-500">
Loading rate limits...
</div>
)}
{rateLimitData && (
<RateLimitDisplay data={rateLimitData} onReset={handleReset} />
)}

View File

@@ -0,0 +1,212 @@
"use client";
import { useState } from "react";
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";
export interface UserOption {
user_id: string;
user_email: string;
}
/**
* Returns true when the input looks like a complete email address.
* Used to decide whether to call the direct email lookup endpoint
* vs. the broader user-history search.
*/
function looksLikeEmail(input: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input);
}
/**
* Returns true when the input looks like a UUID (user ID).
*/
function looksLikeUuid(input: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
input,
);
}
export function useRateLimitManager() {
const { toast } = useToast();
const [isSearching, setIsSearching] = useState(false);
const [isLoadingRateLimit, setIsLoadingRateLimit] = useState(false);
const [searchResults, setSearchResults] = useState<UserOption[]>([]);
const [selectedUser, setSelectedUser] = useState<UserOption | null>(null);
const [rateLimitData, setRateLimitData] =
useState<UserRateLimitResponse | null>(null);
/** Direct lookup by email or user ID via the rate-limit endpoint. */
async function handleDirectLookup(trimmed: string) {
setIsSearching(true);
setSearchResults([]);
setSelectedUser(null);
setRateLimitData(null);
try {
const params = looksLikeEmail(trimmed)
? { email: trimmed }
: { user_id: trimmed };
const response = await getV2GetUserRateLimit(params);
if (response.status !== 200) {
throw new Error("Failed to fetch rate limit");
}
setRateLimitData(response.data);
setSelectedUser({
user_id: response.data.user_id,
user_email: response.data.user_email ?? response.data.user_id,
});
} catch (error) {
console.error("Error fetching rate limit:", error);
const hint = looksLikeEmail(trimmed)
? "No user found with that email address."
: "Check the user ID and try again.";
toast({
title: "Error",
description: `Failed to fetch rate limits. ${hint}`,
variant: "destructive",
});
setRateLimitData(null);
} finally {
setIsSearching(false);
}
}
/** Fuzzy name/email search via the spending-history endpoint. */
async function handleFuzzySearch(trimmed: string) {
setIsSearching(true);
setSearchResults([]);
setSelectedUser(null);
setRateLimitData(null);
try {
const response = await getV2GetAllUsersHistory({
search: trimmed,
page: 1,
page_size: 50,
});
if (response.status !== 200) {
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),
});
}
}
if (users.length === 0) {
toast({
title: "No results",
description: "No users found matching your search.",
});
}
// 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);
toast({
title: "Error",
description: "Failed to search users.",
variant: "destructive",
});
} finally {
setIsSearching(false);
}
}
async function handleSearch(query: string) {
const trimmed = query.trim();
if (!trimmed) return;
// Direct lookup when the input is a full email or UUID.
// This avoids the spending-history indirection and works even for
// users who have no credit transaction history.
if (looksLikeEmail(trimmed) || looksLikeUuid(trimmed)) {
await handleDirectLookup(trimmed);
} else {
await handleFuzzySearch(trimmed);
}
}
async function fetchRateLimit(userId: string) {
setIsLoadingRateLimit(true);
try {
const response = await getV2GetUserRateLimit({ user_id: userId });
if (response.status !== 200) {
throw new Error("Failed to fetch rate limit");
}
setRateLimitData(response.data);
} catch (error) {
console.error("Error fetching rate limit:", error);
toast({
title: "Error",
description: "Failed to fetch user rate limit.",
variant: "destructive",
});
setRateLimitData(null);
} finally {
setIsLoadingRateLimit(false);
}
}
async function handleSelectUser(user: UserOption) {
setSelectedUser(user);
setRateLimitData(null);
await fetchRateLimit(user.user_id);
}
async function handleReset(resetWeekly: boolean) {
if (!rateLimitData) return;
try {
const response = await postV2ResetUserRateLimitUsage({
user_id: rateLimitData.user_id,
reset_weekly: resetWeekly,
});
if (response.status !== 200) {
throw new Error("Failed to reset usage");
}
setRateLimitData(response.data);
toast({
title: "Success",
description: resetWeekly
? "Daily and weekly usage reset to zero."
: "Daily usage reset to zero.",
});
} catch (error) {
console.error("Error resetting rate limit:", error);
toast({
title: "Error",
description: "Failed to reset rate limit usage.",
variant: "destructive",
});
}
}
return {
isSearching,
isLoadingRateLimit,
searchResults,
selectedUser,
rateLimitData,
handleSearch,
handleSelectUser,
handleReset,
};
}

View File

@@ -11,6 +11,7 @@ import { PaginationControls } from "../../../../../components/__legacy__/ui/pagi
import { SearchAndFilterAdminSpending } from "./SearchAndFilterAdminSpending";
import { getUsersTransactionHistory } from "@/app/(platform)/admin/spending/actions";
import { AdminAddMoneyButton } from "./AddMoneyButton";
import { RateLimitModal } from "./RateLimitModal";
import { CreditTransactionType } from "@/lib/autogpt-server-api";
export async function AdminUserGrantHistory({
@@ -80,10 +81,7 @@ export async function AdminUserGrantHistory({
return (
<div className="space-y-4">
<SearchAndFilterAdminSpending
initialStatus={initialStatus}
initialSearch={initialSearch}
/>
<SearchAndFilterAdminSpending initialSearch={initialSearch} />
<div className="rounded-md border bg-white">
<Table>
@@ -105,7 +103,7 @@ export async function AdminUserGrantHistory({
{history.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
colSpan={9}
className="py-10 text-center text-gray-500"
>
No transactions found
@@ -114,7 +112,7 @@ export async function AdminUserGrantHistory({
) : (
history.map((transaction) => (
<TableRow
key={transaction.user_id}
key={`${transaction.user_id}-${transaction.transaction_time}`}
className="hover:bg-gray-50"
>
<TableCell className="font-medium">
@@ -147,25 +145,29 @@ export async function AdminUserGrantHistory({
${transaction.current_balance / 100}
</TableCell> */}
<TableCell className="text-right">
<AdminAddMoneyButton
userId={transaction.user_id}
userEmail={
transaction.user_email ?? "User Email wasn't attached"
}
currentBalance={transaction.current_balance}
defaultAmount={
transaction.transaction_type ===
CreditTransactionType.USAGE
? -transaction.amount
: undefined
}
defaultComments={
transaction.transaction_type ===
CreditTransactionType.USAGE
? "Refund for usage"
: undefined
}
/>
<div className="flex items-center justify-end gap-2">
<RateLimitModal
userId={transaction.user_id}
userEmail={transaction.user_email ?? ""}
/>
<AdminAddMoneyButton
userId={transaction.user_id}
userEmail={transaction.user_email ?? ""}
currentBalance={transaction.current_balance}
defaultAmount={
transaction.transaction_type ===
CreditTransactionType.USAGE
? -transaction.amount
: undefined
}
defaultComments={
transaction.transaction_type ===
CreditTransactionType.USAGE
? "Refund for usage"
: undefined
}
/>
</div>
</TableCell>
</TableRow>
))

View File

@@ -0,0 +1,138 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/atoms/Button/Button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/__legacy__/ui/dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import type { UserRateLimitResponse } from "@/app/api/__generated__/models/userRateLimitResponse";
import {
getV2GetUserRateLimit,
postV2ResetUserRateLimitUsage,
} from "@/app/api/__generated__/endpoints/admin/admin";
import { Gauge } from "@phosphor-icons/react";
import { RateLimitDisplay } from "../../rate-limits/components/RateLimitDisplay";
export function RateLimitModal({
userId,
userEmail,
}: {
userId: string;
userEmail: string;
}) {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [rateLimitData, setRateLimitData] =
useState<UserRateLimitResponse | null>(null);
useEffect(() => {
if (!open) {
setRateLimitData(null);
return;
}
async function fetchRateLimit() {
setIsLoading(true);
try {
const response = await getV2GetUserRateLimit({ user_id: userId });
if (response.status !== 200) {
throw new Error("Failed to fetch rate limit");
}
setRateLimitData(response.data);
} catch (error) {
console.error("Error fetching rate limit:", error);
toast({
title: "Error",
description: "Failed to fetch user rate limit.",
variant: "destructive",
});
setRateLimitData(null);
} finally {
setIsLoading(false);
}
}
fetchRateLimit();
}, [open, userId, toast]);
async function handleReset(resetWeekly: boolean) {
if (!rateLimitData) return;
try {
const response = await postV2ResetUserRateLimitUsage({
user_id: rateLimitData.user_id,
reset_weekly: resetWeekly,
});
if (response.status !== 200) {
throw new Error("Failed to reset usage");
}
setRateLimitData(response.data);
toast({
title: "Success",
description: resetWeekly
? "Daily and weekly usage reset to zero."
: "Daily usage reset to zero.",
});
} catch (error) {
console.error("Error resetting rate limit:", error);
toast({
title: "Error",
description: "Failed to reset rate limit usage.",
variant: "destructive",
});
}
}
return (
<>
<Button
size="small"
variant="outline"
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
>
<Gauge size={16} className="mr-1" />
Rate Limits
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Rate Limits</DialogTitle>
<DialogDescription>
CoPilot rate limits for {userEmail || userId}
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="py-8 text-center text-gray-500">
Loading rate limits...
</div>
)}
{!isLoading && rateLimitData && (
<RateLimitDisplay
data={rateLimitData}
onReset={handleReset}
className="space-y-4"
/>
)}
{!isLoading && !rateLimitData && (
<div className="py-8 text-center text-gray-500">
No rate limit data available for this user.
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -2,9 +2,6 @@
import { useState, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Input } from "@/components/__legacy__/ui/input";
import { Button } from "@/components/__legacy__/ui/button";
import { Search } from "lucide-react";
import { CreditTransactionType } from "@/lib/autogpt-server-api";
import {
Select,
@@ -13,11 +10,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { AdminUserSearch } from "../../components/AdminUserSearch";
export function SearchAndFilterAdminSpending({
initialSearch,
}: {
initialStatus?: CreditTransactionType;
initialSearch?: string;
}) {
const router = useRouter();
@@ -37,11 +34,11 @@ export function SearchAndFilterAdminSpending({
setSearchQuery(searchParams.get("search") || "");
}, [searchParams]);
const handleSearch = () => {
function handleSearch(query: string) {
const params = new URLSearchParams(searchParams.toString());
if (searchQuery) {
params.set("search", searchQuery);
if (query) {
params.set("search", query);
} else {
params.delete("search");
}
@@ -55,21 +52,15 @@ export function SearchAndFilterAdminSpending({
params.set("page", "1"); // Reset to first page on new search
router.push(`${pathname}?${params.toString()}`);
};
}
return (
<div className="flex items-center justify-between">
<div className="flex w-full items-center gap-2">
<Input
placeholder="Search users by Name or Email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button variant="outline" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
<AdminUserSearch
value={searchQuery}
onChange={setSearchQuery}
onSearch={handleSearch}
/>
<Select
value={selectedStatus}

View File

@@ -1442,15 +1442,27 @@
"get": {
"tags": ["v2", "admin", "copilot", "admin"],
"summary": "Get User Rate Limit",
"description": "Get a user's current usage and effective rate limits. Admin-only.",
"description": "Get a user's current usage and effective rate limits. Admin-only.\n\nAccepts either ``user_id`` or ``email`` as a query parameter.\nWhen ``email`` is provided the user is looked up by email first.",
"operationId": "getV2Get user rate limit",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "user_id",
"in": "query",
"required": true,
"schema": { "type": "string", "title": "User Id" }
"required": false,
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
}
},
{
"name": "email",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Email"
}
}
],
"responses": {
@@ -14792,6 +14804,10 @@
"UserRateLimitResponse": {
"properties": {
"user_id": { "type": "string", "title": "User Id" },
"user_email": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Email"
},
"daily_token_limit": {
"type": "integer",
"title": "Daily Token Limit"