mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(platform): admin rate limit check and reset with LD-configurable global limits (#12566)
## Why Admins need visibility into per-user CoPilot rate limit usage and the ability to reset a user's counters when needed (e.g., after a false positive or for debugging). Additionally, the global rate limits were hardcoded deploy-time constants with no way to adjust without redeploying. ## What - Admin endpoints to **check** a user's current rate limit usage and **reset** their daily/weekly counters to zero - Global rate limits are now **LaunchDarkly-configurable** via `copilot-daily-token-limit` and `copilot-weekly-token-limit` flags, falling back to existing `ChatConfig` values - Frontend admin page at `/admin/rate-limits` with user lookup, usage visualization, and reset capability - Chat routes updated to source global limits from LD flags ## How - **Backend**: Added `reset_user_usage()` to `rate_limit.py` that deletes Redis usage keys. New admin routes in `rate_limit_admin_routes.py` (GET `/api/copilot/admin/rate_limit` and POST `/api/copilot/admin/rate_limit/reset`). Added `COPILOT_DAILY_TOKEN_LIMIT` and `COPILOT_WEEKLY_TOKEN_LIMIT` to the `Flag` enum. Chat routes use `_get_global_rate_limits()` helper that checks LD first. - **Frontend**: New `/admin/rate-limits` page with `RateLimitManager` (user lookup) and `RateLimitDisplay` (usage bars + reset button). Added `getUserRateLimit` and `resetUserRateLimit` to `BackendAPI` client. ## Test plan - [x] Backend: 4 tests covering get, reset, redis failure, and admin-only access - [ ] Manual: Look up a user's rate limits in the admin UI - [ ] Manual: Reset a user's usage counters - [ ] Manual: Verify LD flag overrides are respected for global limits
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""Admin endpoints for checking and resetting user CoPilot rate limit usage."""
|
||||
|
||||
import logging
|
||||
|
||||
from autogpt_libs.auth import get_user_id, requires_admin_user
|
||||
from fastapi import APIRouter, Body, HTTPException, Security
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.copilot.config import ChatConfig
|
||||
from backend.copilot.rate_limit import (
|
||||
get_global_rate_limits,
|
||||
get_usage_status,
|
||||
reset_user_usage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
config = ChatConfig()
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["copilot", "admin"],
|
||||
dependencies=[Security(requires_admin_user)],
|
||||
)
|
||||
|
||||
|
||||
class UserRateLimitResponse(BaseModel):
|
||||
user_id: str
|
||||
daily_token_limit: int
|
||||
weekly_token_limit: int
|
||||
daily_tokens_used: int
|
||||
weekly_tokens_used: int
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rate_limit",
|
||||
response_model=UserRateLimitResponse,
|
||||
summary="Get User Rate Limit",
|
||||
)
|
||||
async def get_user_rate_limit(
|
||||
user_id: str,
|
||||
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}")
|
||||
|
||||
daily_limit, weekly_limit = await get_global_rate_limits(
|
||||
user_id, config.daily_token_limit, config.weekly_token_limit
|
||||
)
|
||||
usage = await get_usage_status(user_id, daily_limit, weekly_limit)
|
||||
|
||||
return UserRateLimitResponse(
|
||||
user_id=user_id,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
daily_tokens_used=usage.daily.used,
|
||||
weekly_tokens_used=usage.weekly.used,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rate_limit/reset",
|
||||
response_model=UserRateLimitResponse,
|
||||
summary="Reset User Rate Limit Usage",
|
||||
)
|
||||
async def reset_user_rate_limit(
|
||||
user_id: str = Body(embed=True),
|
||||
reset_weekly: bool = Body(False, embed=True),
|
||||
admin_user_id: str = Security(get_user_id),
|
||||
) -> 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})"
|
||||
)
|
||||
|
||||
try:
|
||||
await reset_user_usage(user_id, reset_weekly=reset_weekly)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to reset user usage")
|
||||
raise HTTPException(status_code=500, detail="Failed to reset usage") from e
|
||||
|
||||
daily_limit, weekly_limit = await get_global_rate_limits(
|
||||
user_id, config.daily_token_limit, config.weekly_token_limit
|
||||
)
|
||||
usage = await get_usage_status(user_id, daily_limit, weekly_limit)
|
||||
|
||||
return UserRateLimitResponse(
|
||||
user_id=user_id,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
daily_tokens_used=usage.daily.used,
|
||||
weekly_tokens_used=usage.weekly.used,
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import fastapi
|
||||
import fastapi.testclient
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from autogpt_libs.auth.jwt_utils import get_jwt_payload
|
||||
from pytest_snapshot.plugin import Snapshot
|
||||
|
||||
from backend.copilot.rate_limit import CoPilotUsageStatus, UsageWindow
|
||||
|
||||
from .rate_limit_admin_routes import router as rate_limit_admin_router
|
||||
|
||||
app = fastapi.FastAPI()
|
||||
app.include_router(rate_limit_admin_router)
|
||||
|
||||
client = fastapi.testclient.TestClient(app)
|
||||
|
||||
_MOCK_MODULE = "backend.api.features.admin.rate_limit_admin_routes"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_app_admin_auth(mock_jwt_admin):
|
||||
"""Setup admin auth overrides for all tests in this module"""
|
||||
app.dependency_overrides[get_jwt_payload] = mock_jwt_admin["get_jwt_payload"]
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _mock_usage_status(
|
||||
daily_used: int = 500_000, weekly_used: int = 3_000_000
|
||||
) -> CoPilotUsageStatus:
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
now = datetime.now(UTC)
|
||||
return CoPilotUsageStatus(
|
||||
daily=UsageWindow(
|
||||
used=daily_used, limit=2_500_000, resets_at=now + timedelta(hours=6)
|
||||
),
|
||||
weekly=UsageWindow(
|
||||
used=weekly_used, limit=12_500_000, resets_at=now + timedelta(days=3)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_global_rate_limits",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(2_500_000, 12_500_000),
|
||||
)
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_usage_status",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_mock_usage_status(),
|
||||
)
|
||||
|
||||
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["daily_token_limit"] == 2_500_000
|
||||
assert data["weekly_token_limit"] == 12_500_000
|
||||
assert data["daily_tokens_used"] == 500_000
|
||||
assert data["weekly_tokens_used"] == 3_000_000
|
||||
|
||||
configured_snapshot.assert_match(
|
||||
json.dumps(data, indent=2, sort_keys=True) + "\n",
|
||||
"get_rate_limit",
|
||||
)
|
||||
|
||||
|
||||
def test_reset_user_usage_daily_only(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
configured_snapshot: Snapshot,
|
||||
target_user_id: str,
|
||||
) -> None:
|
||||
"""Test resetting only daily usage (default behaviour)."""
|
||||
mock_reset = mocker.patch(
|
||||
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),
|
||||
)
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_usage_status",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_mock_usage_status(daily_used=0, weekly_used=3_000_000),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/admin/rate_limit/reset",
|
||||
json={"user_id": target_user_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["daily_tokens_used"] == 0
|
||||
# Weekly is untouched
|
||||
assert data["weekly_tokens_used"] == 3_000_000
|
||||
|
||||
mock_reset.assert_awaited_once_with(target_user_id, reset_weekly=False)
|
||||
|
||||
configured_snapshot.assert_match(
|
||||
json.dumps(data, indent=2, sort_keys=True) + "\n",
|
||||
"reset_user_usage_daily_only",
|
||||
)
|
||||
|
||||
|
||||
def test_reset_user_usage_daily_and_weekly(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
configured_snapshot: Snapshot,
|
||||
target_user_id: str,
|
||||
) -> None:
|
||||
"""Test resetting both daily and weekly usage."""
|
||||
mock_reset = mocker.patch(
|
||||
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),
|
||||
)
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.get_usage_status",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_mock_usage_status(daily_used=0, weekly_used=0),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/admin/rate_limit/reset",
|
||||
json={"user_id": target_user_id, "reset_weekly": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["daily_tokens_used"] == 0
|
||||
assert data["weekly_tokens_used"] == 0
|
||||
|
||||
mock_reset.assert_awaited_once_with(target_user_id, reset_weekly=True)
|
||||
|
||||
configured_snapshot.assert_match(
|
||||
json.dumps(data, indent=2, sort_keys=True) + "\n",
|
||||
"reset_user_usage_daily_and_weekly",
|
||||
)
|
||||
|
||||
|
||||
def test_reset_user_usage_redis_failure(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
target_user_id: str,
|
||||
) -> None:
|
||||
"""Test that Redis failure on reset returns 500."""
|
||||
mocker.patch(
|
||||
f"{_MOCK_MODULE}.reset_user_usage",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Redis connection refused"),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/admin/rate_limit/reset",
|
||||
json={"user_id": target_user_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
response = client.get("/admin/rate_limit", params={"user_id": "test"})
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.post(
|
||||
"/admin/rate_limit/reset",
|
||||
json={"user_id": "test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -31,6 +31,7 @@ from backend.copilot.rate_limit import (
|
||||
CoPilotUsageStatus,
|
||||
RateLimitExceeded,
|
||||
check_rate_limit,
|
||||
get_global_rate_limits,
|
||||
get_usage_status,
|
||||
)
|
||||
from backend.copilot.response_model import StreamError, StreamFinish, StreamHeartbeat
|
||||
@@ -64,14 +65,14 @@ from backend.data.understanding import get_business_understanding
|
||||
from backend.data.workspace import get_or_create_workspace
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
config = ChatConfig()
|
||||
|
||||
_UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _validate_and_get_session(
|
||||
session_id: str,
|
||||
@@ -422,11 +423,15 @@ async def get_copilot_usage(
|
||||
"""Get CoPilot usage status for the authenticated user.
|
||||
|
||||
Returns current token usage vs limits for daily and weekly windows.
|
||||
Global defaults sourced from LaunchDarkly (falling back to config).
|
||||
"""
|
||||
daily_limit, weekly_limit = await get_global_rate_limits(
|
||||
user_id, config.daily_token_limit, config.weekly_token_limit
|
||||
)
|
||||
return await get_usage_status(
|
||||
user_id=user_id,
|
||||
daily_token_limit=config.daily_token_limit,
|
||||
weekly_token_limit=config.weekly_token_limit,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
)
|
||||
|
||||
|
||||
@@ -527,12 +532,16 @@ async def stream_chat_post(
|
||||
|
||||
# Pre-turn rate limit check (token-based).
|
||||
# check_rate_limit short-circuits internally when both limits are 0.
|
||||
# Global defaults sourced from LaunchDarkly, falling back to config.
|
||||
if user_id:
|
||||
try:
|
||||
daily_limit, weekly_limit = await get_global_rate_limits(
|
||||
user_id, config.daily_token_limit, config.weekly_token_limit
|
||||
)
|
||||
await check_rate_limit(
|
||||
user_id=user_id,
|
||||
daily_token_limit=config.daily_token_limit,
|
||||
weekly_token_limit=config.weekly_token_limit,
|
||||
daily_token_limit=daily_limit,
|
||||
weekly_token_limit=weekly_limit,
|
||||
)
|
||||
except RateLimitExceeded as e:
|
||||
raise HTTPException(status_code=429, detail=str(e)) from e
|
||||
|
||||
@@ -18,6 +18,7 @@ from prisma.errors import PrismaError
|
||||
|
||||
import backend.api.features.admin.credit_admin_routes
|
||||
import backend.api.features.admin.execution_analytics_routes
|
||||
import backend.api.features.admin.rate_limit_admin_routes
|
||||
import backend.api.features.admin.store_admin_routes
|
||||
import backend.api.features.builder
|
||||
import backend.api.features.builder.routes
|
||||
@@ -318,6 +319,11 @@ app.include_router(
|
||||
tags=["v2", "admin"],
|
||||
prefix="/api/executions",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.admin.rate_limit_admin_routes.router,
|
||||
tags=["v2", "admin"],
|
||||
prefix="/api/copilot",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.executions.review.routes.router,
|
||||
tags=["v2", "executions", "review"],
|
||||
|
||||
@@ -231,6 +231,67 @@ async def record_token_usage(
|
||||
)
|
||||
|
||||
|
||||
async def get_global_rate_limits(
|
||||
user_id: str,
|
||||
config_daily: int,
|
||||
config_weekly: int,
|
||||
) -> tuple[int, int]:
|
||||
"""Resolve global rate limits from LaunchDarkly, falling back to config.
|
||||
|
||||
Args:
|
||||
user_id: User ID for LD flag evaluation context.
|
||||
config_daily: Fallback daily limit from ChatConfig.
|
||||
config_weekly: Fallback weekly limit from ChatConfig.
|
||||
|
||||
Returns:
|
||||
(daily_token_limit, weekly_token_limit) tuple.
|
||||
"""
|
||||
# Lazy import to avoid circular dependency:
|
||||
# rate_limit -> feature_flag -> settings -> ... -> rate_limit
|
||||
from backend.util.feature_flag import Flag, get_feature_flag_value
|
||||
|
||||
daily_raw = await get_feature_flag_value(
|
||||
Flag.COPILOT_DAILY_TOKEN_LIMIT.value, user_id, config_daily
|
||||
)
|
||||
weekly_raw = await get_feature_flag_value(
|
||||
Flag.COPILOT_WEEKLY_TOKEN_LIMIT.value, user_id, config_weekly
|
||||
)
|
||||
try:
|
||||
daily = max(0, int(daily_raw))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid LD value for daily token limit: %r", daily_raw)
|
||||
daily = config_daily
|
||||
try:
|
||||
weekly = max(0, int(weekly_raw))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid LD value for weekly token limit: %r", weekly_raw)
|
||||
weekly = config_weekly
|
||||
return daily, weekly
|
||||
|
||||
|
||||
async def reset_user_usage(user_id: str, *, reset_weekly: bool = False) -> None:
|
||||
"""Reset a user's usage counters.
|
||||
|
||||
Always deletes the daily Redis key. When *reset_weekly* is ``True``,
|
||||
the weekly key is deleted as well.
|
||||
|
||||
Unlike read paths (``get_usage_status``, ``check_rate_limit``) which
|
||||
fail-open on Redis errors, resets intentionally re-raise so the caller
|
||||
knows the operation did not succeed. A silent failure here would leave
|
||||
the admin believing the counters were zeroed when they were not.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
keys_to_delete = [_daily_key(user_id, now=now)]
|
||||
if reset_weekly:
|
||||
keys_to_delete.append(_weekly_key(user_id, now=now))
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
await redis.delete(*keys_to_delete)
|
||||
except (RedisError, ConnectionError, OSError):
|
||||
logger.warning("Redis unavailable for resetting user usage")
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -39,6 +39,8 @@ class Flag(str, Enum):
|
||||
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment"
|
||||
CHAT = "chat"
|
||||
COPILOT_SDK = "copilot-sdk"
|
||||
COPILOT_DAILY_TOKEN_LIMIT = "copilot-daily-token-limit"
|
||||
COPILOT_WEEKLY_TOKEN_LIMIT = "copilot-weekly-token-limit"
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
|
||||
7
autogpt_platform/backend/snapshots/get_rate_limit
Normal file
7
autogpt_platform/backend/snapshots/get_rate_limit
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"daily_token_limit": 2500000,
|
||||
"daily_tokens_used": 500000,
|
||||
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
|
||||
"weekly_token_limit": 12500000,
|
||||
"weekly_tokens_used": 3000000
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"daily_token_limit": 2500000,
|
||||
"daily_tokens_used": 0,
|
||||
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
|
||||
"weekly_token_limit": 12500000,
|
||||
"weekly_tokens_used": 0
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"daily_token_limit": 2500000,
|
||||
"daily_tokens_used": 0,
|
||||
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
|
||||
"weekly_token_limit": 12500000,
|
||||
"weekly_tokens_used": 3000000
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Sidebar } from "@/components/__legacy__/Sidebar";
|
||||
import { Users, DollarSign, UserSearch, FileText } from "lucide-react";
|
||||
import { Gauge } from "@phosphor-icons/react/dist/ssr";
|
||||
|
||||
import { IconSliders } from "@/components/__legacy__/ui/icons";
|
||||
|
||||
@@ -21,6 +22,11 @@ const sidebarLinkGroups = [
|
||||
href: "/admin/impersonation",
|
||||
icon: <UserSearch className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "Rate Limits",
|
||||
href: "/admin/rate-limits",
|
||||
icon: <Gauge className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "Execution Analytics",
|
||||
href: "/admin/execution-analytics",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: UserRateLimitResponse;
|
||||
onReset: (resetWeekly: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RateLimitDisplay({ data, onReset }: Props) {
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetWeekly, setResetWeekly] = useState(false);
|
||||
|
||||
async function handleReset() {
|
||||
const msg = resetWeekly
|
||||
? "Reset both daily and weekly usage counters to zero?"
|
||||
: "Reset daily usage counter to zero?";
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
setIsResetting(true);
|
||||
try {
|
||||
await onReset(resetWeekly);
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const nothingToReset = resetWeekly
|
||||
? data.daily_tokens_used === 0 && data.weekly_tokens_used === 0
|
||||
: 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}
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<UsageBar
|
||||
used={data.weekly_tokens_used}
|
||||
limit={data.weekly_token_limit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3 border-t pt-4">
|
||||
<select
|
||||
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"
|
||||
disabled={isResetting}
|
||||
>
|
||||
<option value="daily">Reset daily only</option>
|
||||
<option value="both">Reset daily + weekly</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting || nothingToReset}
|
||||
>
|
||||
{isResetting ? "Resetting..." : "Reset Usage"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"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 { RateLimitDisplay } from "./RateLimitDisplay";
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{rateLimitData && (
|
||||
<RateLimitDisplay data={rateLimitData} onReset={handleReset} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { withRoleAccess } from "@/lib/withRoleAccess";
|
||||
import { RateLimitManager } from "./components/RateLimitManager";
|
||||
|
||||
function RateLimitsDashboard() {
|
||||
return (
|
||||
<div className="mx-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">User Rate Limits</h1>
|
||||
<p className="text-gray-500">
|
||||
Check and manage CoPilot rate limits per user
|
||||
</p>
|
||||
</div>
|
||||
<RateLimitManager />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function RateLimitsDashboardPage() {
|
||||
"use server";
|
||||
const withAdminAccess = await withRoleAccess(["admin"]);
|
||||
const ProtectedDashboard = await withAdminAccess(RateLimitsDashboard);
|
||||
return <ProtectedDashboard />;
|
||||
}
|
||||
@@ -1386,7 +1386,7 @@
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Get Copilot Usage",
|
||||
"description": "Get CoPilot usage status for the authenticated user.\n\nReturns current token usage vs limits for daily and weekly windows.",
|
||||
"description": "Get CoPilot usage status for the authenticated user.\n\nReturns current token usage vs limits for daily and weekly windows.\nGlobal defaults sourced from LaunchDarkly (falling back to config).",
|
||||
"operationId": "getV2GetCopilotUsage",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -1404,6 +1404,88 @@
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/copilot/admin/rate_limit": {
|
||||
"get": {
|
||||
"tags": ["v2", "admin", "copilot", "admin"],
|
||||
"summary": "Get User Rate Limit",
|
||||
"description": "Get a user's current usage and effective rate limits. Admin-only.",
|
||||
"operationId": "getV2Get user rate limit",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "User Id" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserRateLimitResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/copilot/admin/rate_limit/reset": {
|
||||
"post": {
|
||||
"tags": ["v2", "admin", "copilot", "admin"],
|
||||
"summary": "Reset User Rate Limit Usage",
|
||||
"description": "Reset a user's daily usage counter (and optionally weekly). Admin-only.",
|
||||
"operationId": "postV2Reset user rate limit usage",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_postV2Reset_user_rate_limit_usage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserRateLimitResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/credits": {
|
||||
"get": {
|
||||
"tags": ["v1", "credits"],
|
||||
@@ -8155,6 +8237,19 @@
|
||||
"type": "object",
|
||||
"title": "Body_postV2Execute a preset"
|
||||
},
|
||||
"Body_postV2Reset_user_rate_limit_usage": {
|
||||
"properties": {
|
||||
"user_id": { "type": "string", "title": "User Id" },
|
||||
"reset_weekly": {
|
||||
"type": "boolean",
|
||||
"title": "Reset Weekly",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["user_id"],
|
||||
"title": "Body_postV2Reset user rate limit usage"
|
||||
},
|
||||
"Body_postV2Upload_submission_media": {
|
||||
"properties": {
|
||||
"file": { "type": "string", "format": "binary", "title": "File" }
|
||||
@@ -14533,6 +14628,36 @@
|
||||
"required": ["provider", "username", "password"],
|
||||
"title": "UserPasswordCredentials"
|
||||
},
|
||||
"UserRateLimitResponse": {
|
||||
"properties": {
|
||||
"user_id": { "type": "string", "title": "User Id" },
|
||||
"daily_token_limit": {
|
||||
"type": "integer",
|
||||
"title": "Daily Token Limit"
|
||||
},
|
||||
"weekly_token_limit": {
|
||||
"type": "integer",
|
||||
"title": "Weekly Token Limit"
|
||||
},
|
||||
"daily_tokens_used": {
|
||||
"type": "integer",
|
||||
"title": "Daily Tokens Used"
|
||||
},
|
||||
"weekly_tokens_used": {
|
||||
"type": "integer",
|
||||
"title": "Weekly Tokens Used"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"user_id",
|
||||
"daily_token_limit",
|
||||
"weekly_token_limit",
|
||||
"daily_tokens_used",
|
||||
"weekly_tokens_used"
|
||||
],
|
||||
"title": "UserRateLimitResponse"
|
||||
},
|
||||
"UserReadiness": {
|
||||
"properties": {
|
||||
"has_all_credentials": {
|
||||
|
||||
@@ -964,6 +964,7 @@ export type AddUserCreditsResponse = {
|
||||
new_balance: number;
|
||||
transaction_key: string;
|
||||
};
|
||||
|
||||
const _stringFormatToDataTypeMap: Partial<Record<string, DataType>> = {
|
||||
date: DataType.DATE,
|
||||
time: DataType.TIME,
|
||||
|
||||
Reference in New Issue
Block a user