From 3f8be01337c8c64cf7eafa87857ba348326dcf6f Mon Sep 17 00:00:00 2001 From: kartik Date: Sun, 13 Sep 2026 23:28:54 +0530 Subject: [PATCH 1/3] change budget from 10$ to 2$ --- backend/.env.example | 2 +- backend/app/core/config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 07db179..0280c10 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,5 +30,5 @@ SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key # LiteLLM Proxy Configuration LITELLM_URL=http://litellm:4000 LITELLM_MASTER_KEY=sk-litellm-change-me -DEFAULT_USER_BUDGET=10.00 +DEFAULT_USER_BUDGET=2.00 DEFAULT_BUDGET_DURATION=30d diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3ed43bc..5c6362d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -43,7 +43,7 @@ class Settings(BaseSettings): # LiteLLM Proxy Config LITELLM_URL: str = "http://litellm:4000" LITELLM_MASTER_KEY: Optional[str] = None - DEFAULT_USER_BUDGET: Decimal = Decimal("10.00") + DEFAULT_USER_BUDGET: Decimal = Decimal("2.00") DEFAULT_BUDGET_DURATION: str = "30d" From 642fe4d06a53f9fff444cb931c82aae6bddbf76e Mon Sep 17 00:00:00 2001 From: kartik Date: Mon, 14 Sep 2026 00:30:25 +0530 Subject: [PATCH 2/3] add admin endpoints to bulk-update user budgets --- backend/app/controllers/litellm_controller.py | 38 ++++++ backend/app/main.py | 2 + backend/app/routes/litellm_router.py | 39 ++++++ backend/app/schemas/litellm_schema.py | 33 +++++ backend/app/services/litellm_service.py | 126 ++++++++++++++++++ 5 files changed, 238 insertions(+) create mode 100644 backend/app/controllers/litellm_controller.py create mode 100644 backend/app/routes/litellm_router.py create mode 100644 backend/app/schemas/litellm_schema.py diff --git a/backend/app/controllers/litellm_controller.py b/backend/app/controllers/litellm_controller.py new file mode 100644 index 0000000..2271152 --- /dev/null +++ b/backend/app/controllers/litellm_controller.py @@ -0,0 +1,38 @@ +import httpx +from fastapi import HTTPException + +from app.services.litellm_service import list_managed_users, update_all_user_budgets + + +async def list_managed_users_controller() -> dict: + """ + List the LiteLLM users a bulk budget update would apply to. + """ + + try: + users = await list_managed_users() + + except httpx.HTTPError as error: + raise HTTPException( + status_code=502, + detail=f"Failed to fetch users from LiteLLM: {error}", + ) + + return {"users": users, "total": len(users)} + + +async def update_all_budgets_controller( + max_budget: float, budget_duration: str +) -> dict: + """ + Apply the same max budget and reset frequency to every managed LiteLLM user. + """ + + try: + return await update_all_user_budgets(max_budget, budget_duration) + + except httpx.HTTPError as error: + raise HTTPException( + status_code=502, + detail=f"Failed to update budgets in LiteLLM: {error}", + ) diff --git a/backend/app/main.py b/backend/app/main.py index 1ec28d8..e46e6d6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from app.routes.knowledge_base_router import router as knowledge_base_router from app.routes.users_router import router as users_router from app.routes.vcell_identity_router import router as vcell_identity_router +from app.routes.litellm_router import router as litellm_router ascii_art = """ ╔════════════════════════════════════════════════════════════════════════════════════╗ @@ -60,6 +61,7 @@ async def startup_event(): app.include_router(qdrant_router, tags=["Qdrant Vector DB"], prefix="/qdrant") app.include_router(users_router, tags=["Users"]) app.include_router(vcell_identity_router, tags=["VCell Account Linking"]) +app.include_router(litellm_router, tags=["LiteLLM Admin"], prefix="/litellm") if __name__ == "__main__": uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/app/routes/litellm_router.py b/backend/app/routes/litellm_router.py new file mode 100644 index 0000000..26266a9 --- /dev/null +++ b/backend/app/routes/litellm_router.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends + +from app.controllers.litellm_controller import ( + list_managed_users_controller, + update_all_budgets_controller, +) +from app.core.auth import require_admin +from app.schemas.litellm_schema import ( + ManagedUsersResponse, + UpdateAllBudgetsRequest, + UpdateAllBudgetsResponse, +) + +# All LiteLLM management endpoints are admin-only. +router = APIRouter(dependencies=[Depends(require_admin)]) + + +@router.get("/users", response_model=ManagedUsersResponse) +async def list_litellm_users(): + """ + Endpoint to list the LiteLLM users a bulk budget update applies to. + + Excludes the LiteLLM proxy admin / default user, whose budget stays + unlimited. + """ + + return await list_managed_users_controller() + + +@router.post("/budgets", response_model=UpdateAllBudgetsResponse) +async def update_all_litellm_budgets(request: UpdateAllBudgetsRequest): + """ + Endpoint to set the same max budget and reset frequency on every LiteLLM + user except the proxy admin / default user. + """ + + return await update_all_budgets_controller( + request.max_budget, request.budget_duration + ) diff --git a/backend/app/schemas/litellm_schema.py b/backend/app/schemas/litellm_schema.py new file mode 100644 index 0000000..2021948 --- /dev/null +++ b/backend/app/schemas/litellm_schema.py @@ -0,0 +1,33 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field + +# Mirrors LiteLLM's budget reset options (hourly / daily / weekly / monthly). +BudgetDuration = Literal["1h", "24h", "7d", "30d"] + + +class UpdateAllBudgetsRequest(BaseModel): + max_budget: float = Field(ge=0, description="Max budget in USD per user") + budget_duration: BudgetDuration = Field( + description="How often each user's budget resets" + ) + + +class ManagedUser(BaseModel): + user_id: str + user_email: Optional[str] = None + spend: float + max_budget: Optional[float] = None + budget_duration: Optional[str] = None + + +class ManagedUsersResponse(BaseModel): + users: list[ManagedUser] + total: int + + +class UpdateAllBudgetsResponse(BaseModel): + total_users: int + successful_updates: int + failed_updates: int + failed_user_ids: list[str] = [] diff --git a/backend/app/services/litellm_service.py b/backend/app/services/litellm_service.py index 8d74967..c40ab79 100644 --- a/backend/app/services/litellm_service.py +++ b/backend/app/services/litellm_service.py @@ -98,3 +98,129 @@ async def get_user_budget_info(auth0_sub: str) -> dict: "max_budget": max_budget, "remaining_budget": remaining_budget, } + + +# LiteLLM's built-in proxy admin. Its budget must stay unlimited, so it is +# always excluded from bulk budget updates. +LITELLM_EXCLUDED_USER_IDS = {"default_user_id"} +LITELLM_EXCLUDED_ROLES = {"proxy_admin", "proxy_admin_viewer"} + +# /user/list caps page_size at 100. +_USER_LIST_PAGE_SIZE = 100 + + +def _is_excluded_from_bulk_update(user: dict) -> bool: + """ + Return True for LiteLLM users that must never be touched by a bulk budget + update (the proxy admin / default user). + """ + return ( + user.get("user_id") in LITELLM_EXCLUDED_USER_IDS + or user.get("user_role") in LITELLM_EXCLUDED_ROLES + ) + + +async def list_managed_users() -> list[dict]: + """ + List every LiteLLM user eligible for bulk budget updates, i.e. all users + except the proxy admin / default user. + + Returns: + list[dict]: user_id, user_email, spend, max_budget and budget_duration + for each managed user. + """ + users: list[dict] = [] + page = 1 + + async with httpx.AsyncClient(timeout=30.0) as client: + while True: + response = await client.get( + f"{settings.LITELLM_URL}/user/list", + headers={"Authorization": f"Bearer {settings.LITELLM_MASTER_KEY}"}, + params={"page": page, "page_size": _USER_LIST_PAGE_SIZE}, + ) + response.raise_for_status() + data = response.json() + + users.extend(data.get("users") or []) + + if page >= (data.get("total_pages") or 1): + break + page += 1 + + return [ + { + "user_id": user.get("user_id"), + "user_email": user.get("user_email"), + "spend": user.get("spend") or 0.0, + "max_budget": user.get("max_budget"), + "budget_duration": user.get("budget_duration"), + } + for user in users + if not _is_excluded_from_bulk_update(user) + ] + + +async def update_all_user_budgets(max_budget: float, budget_duration: str) -> dict: + """ + Set the same max budget and reset frequency on every managed LiteLLM user. + + LiteLLM's own "update all users" option would also overwrite the proxy + admin's unlimited budget, so the user list is fetched and filtered first and + the updates are sent as an explicit per-user batch. + + Args: + max_budget (float): Max budget in USD to apply to each managed user. + budget_duration (str): LiteLLM duration string the budget resets on + (e.g. "1h", "24h", "7d", "30d"). + + Returns: + dict: total_users, successful_updates, failed_updates and the user_ids + of any users that could not be updated. + """ + managed_users = await list_managed_users() + + if not managed_users: + return { + "total_users": 0, + "successful_updates": 0, + "failed_updates": 0, + "failed_user_ids": [], + } + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{settings.LITELLM_URL}/user/bulk_update", + headers={"Authorization": f"Bearer {settings.LITELLM_MASTER_KEY}"}, + json={ + "users": [ + { + "user_id": user["user_id"], + "max_budget": max_budget, + "budget_duration": budget_duration, + } + for user in managed_users + ] + }, + ) + response.raise_for_status() + data = response.json() + + failed_user_ids = [ + result.get("user_id") + for result in (data.get("results") or []) + if not result.get("success") + ] + + logger.info( + f"Bulk budget update: {data.get('successful_updates')} succeeded, " + f"{data.get('failed_updates')} failed " + f"(max_budget={max_budget}, budget_duration={budget_duration})" + ) + + return { + "total_users": data.get("total_requested") or len(managed_users), + "successful_updates": data.get("successful_updates") or 0, + "failed_updates": data.get("failed_updates") or 0, + "failed_user_ids": failed_user_ids, + } From 0181fd86d5430ef6554004de858352c19637bc0e Mon Sep 17 00:00:00 2001 From: kartik Date: Mon, 14 Sep 2026 00:30:48 +0530 Subject: [PATCH 3/3] add LiteLLM budgets page to admin sidebar --- frontend/app/admin/litellm/page.tsx | 299 ++++++++++++++++++++++++++++ frontend/components/app-sidebar.tsx | 17 ++ 2 files changed, 316 insertions(+) create mode 100644 frontend/app/admin/litellm/page.tsx diff --git a/frontend/app/admin/litellm/page.tsx b/frontend/app/admin/litellm/page.tsx new file mode 100644 index 0000000..5a113c9 --- /dev/null +++ b/frontend/app/admin/litellm/page.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getAccessToken } from "@auth0/nextjs-auth0/client"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SignInOutButton } from "@/components/sign-in-out-button"; +import { Gauge, Users, ShieldCheck, Save } from "lucide-react"; + +interface ManagedUser { + user_id: string; + user_email: string | null; + spend: number; + max_budget: number | null; + budget_duration: string | null; +} + +interface UpdateResult { + total_users: number; + successful_updates: number; + failed_updates: number; + failed_user_ids: string[]; +} + +// Values are LiteLLM duration strings; labels match the LiteLLM dashboard. +const RESET_OPTIONS = [ + { value: "1h", label: "hourly" }, + { value: "24h", label: "daily" }, + { value: "7d", label: "weekly" }, + { value: "30d", label: "monthly" }, +]; + +const formatUsd = (value: number | null): string => { + if (value === null) return "Unlimited"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 4, + }).format(value); +}; + +const formatDuration = (value: string | null): string => { + if (!value) return "Never resets"; + return RESET_OPTIONS.find((option) => option.value === value)?.label ?? value; +}; + +export default function LiteLLMAdminPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [maxBudget, setMaxBudget] = useState("2"); + const [budgetDuration, setBudgetDuration] = useState("30d"); + const [saving, setSaving] = useState(false); + const [result, setResult] = useState(null); + + const fetchUsers = async () => { + try { + setLoading(true); + setError(""); + const token = await getAccessToken(); + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/litellm/users`, + { + headers: { + accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }, + ); + if (!res.ok) throw new Error(`Request failed with status ${res.status}`); + const data = await res.json(); + setUsers(data.users ?? []); + } catch { + setError("Failed to load LiteLLM users"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUsers(); + }, []); + + const handleSave = async () => { + const parsedBudget = Number(maxBudget); + if (!Number.isFinite(parsedBudget) || parsedBudget < 0) { + setError("Max budget must be a number greater than or equal to 0"); + return; + } + + if ( + !window.confirm( + `Set every user's budget to ${formatUsd(parsedBudget)}, resetting ${formatDuration( + budgetDuration, + )}? This applies to all ${users.length} users below.`, + ) + ) { + return; + } + + try { + setSaving(true); + setError(""); + setResult(null); + const token = await getAccessToken(); + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/litellm/budgets`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + max_budget: parsedBudget, + budget_duration: budgetDuration, + }), + }, + ); + if (!res.ok) throw new Error(`Request failed with status ${res.status}`); + setResult((await res.json()) as UpdateResult); + await fetchUsers(); + } catch { + setError("Failed to update budgets"); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+

+ + LiteLLM Budgets +

+

+ Set the max budget and reset frequency for every user at once +

+
+ +
+
+ + {/* Bulk update form */} + + + + + Update All User Budgets + + {users.length} users + + + + +
+ + + The LiteLLM proxy admin (default_user_id) is always + excluded and keeps its unlimited budget. + +
+ +
+
+ + setMaxBudget(e.target.value)} + /> +
+ +
+ + +
+
+ + {error &&

{error}

} + + {result && ( +
+ Updated {result.successful_updates} of {result.total_users}{" "} + users + {result.failed_updates > 0 && ( + <> + {" "} + — {result.failed_updates} failed:{" "} + {result.failed_user_ids.join(", ")} + + )} +
+ )} + +
+ +
+
+
+ + {/* Affected users */} + + + + + Affected Users + + + + {loading ? ( +
+ Loading users... +
+ ) : users.length === 0 ? ( +
+ No LiteLLM users found. +
+ ) : ( +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
EmailUser IDSpendMax BudgetResets
+ {user.user_email ?? "—"} + + {user.user_id} + + {formatUsd(user.spend)} + + {formatUsd(user.max_budget)} + + {formatDuration(user.budget_duration)} +
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index 4dab984..92b9f3a 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -6,6 +6,7 @@ import { Sparkles, FlaskConical, FolderOpen, + Gauge, MessageSquare, Pencil, Trash2, @@ -451,6 +452,22 @@ export function AppSidebar() { + + + + + {!isCollapsed && LiteLLM} + + +