diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7a791ff7876..070f867a492 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -725,6 +725,7 @@ class LiteLLMRoutes(enum.Enum): "/jwt/key/mapping/list", "/jwt/key/mapping/info", "/management/v1/customer_organizations", + "/management/v1/customer_organizations/daily/activity/aggregated", "/management/v1/customer_organizations/{customer_org_id}", "/management/v1/customer_organizations/daily/activity", "/management/v1/customer_organizations/{customer_org_id}/default_budgets", diff --git a/litellm/proxy/management_endpoints/management_v1/customer_usage.py b/litellm/proxy/management_endpoints/management_v1/customer_usage.py index 4f0a7fc154e..a564e7f281a 100644 --- a/litellm/proxy/management_endpoints/management_v1/customer_usage.py +++ b/litellm/proxy/management_endpoints/management_v1/customer_usage.py @@ -3,18 +3,27 @@ from enum import Enum from functools import partial from typing import Annotated, Final, Protocol -from fastapi import APIRouter, Depends -from pydantic import TypeAdapter +from fastapi import APIRouter, Depends, Query +from pydantic import AfterValidator, TypeAdapter from litellm.litellm_core_utils.core_helpers import require_attributes from litellm.proxy.list_api.common import reject_unknown_query_params -from litellm.proxy.management_endpoints.common_daily_activity import DailySpendRecord, get_daily_activity +from litellm.proxy.management_endpoints.common_daily_activity import ( + DailySpendRecord, + InvalidDateRange, + daily_activity_repository, + daily_activity_scope, + get_daily_activity, + get_daily_activity_aggregated, +) +from litellm.proxy.management_endpoints.common_daily_activity import raise_public as raise_activity_error from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX from litellm.proxy.management_endpoints.management_v1.customers import ( raise_public, reject_duplicate_list_params, require_customer_admin, ) +from litellm.proxy.management_endpoints.team_endpoints import aggregated_date_range_error from litellm.proxy.management_helpers.customer_hierarchy import ErrorCode, Failure, PrismaRecord from litellm.proxy.management_helpers.utils import ( management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy decorator is untyped @@ -74,7 +83,13 @@ async def page_aliases( table: TableActions[PrismaRecord], entity: CustomerEntity, records: Sequence[DailySpendRecord] ) -> dict[str, dict[str, object]]: # mutable-ok: get_daily_activity requires dictionary-returning metadata callbacks identifiers: Final = tuple(_OPTIONAL_TEXT.validate_python(getattr(row, entity.value, None)) for row in records) - rows: Final = await find_many_in(table, entity.value, tuple(value for value in identifiers if value)) + return await customer_aliases(table, entity, tuple(value for value in identifiers if value)) + + +async def customer_aliases( + table: TableActions[PrismaRecord], entity: CustomerEntity, identifiers: Sequence[str] +) -> dict[str, dict[str, object]]: # mutable-ok: shared daily activity helper accepts dictionary-valued metadata + rows: Final = await find_many_in(table, entity.value, identifiers) values: Final = tuple(row.model_dump() for row in rows) return { _TEXT.validate_python(row[entity.value]): { @@ -124,6 +139,65 @@ router: Final = APIRouter( ) +def bounded_organizations(value: str) -> str: + ids: Final = tuple(part.strip() for part in value.split(",") if part.strip()) + if not ids or len(ids) > 100: + raise ValueError("Provide between 1 and 100 customer organization IDs") + return ",".join(ids) + + +@router.get( + "/customer_organizations/daily/activity/aggregated", + response_model=SpendAnalyticsPaginatedResponse, + description=( + "Get complete daily usage totals for up to 100 customer organizations in one response. " + "Uses shared SQL daily-spend aggregation with per-organization breakdowns. " + "Dates are inclusive UTC days, limited to 400 days. Model/API-key filters apply before aggregation. " + "Key breakdowns show the top key; totals include every key. No raw-row pagination is required. " + "Budget counters are returned separately by organization reads. Proxy admins only." + ), +) +async def customer_organization_daily_activity_aggregated( + prisma_client: Annotated[PrismaClient, Depends(get_customer_usage_prisma)], + start_date: Annotated[str, Query(description="First UTC calendar day, YYYY-MM-DD, inclusive")], + end_date: Annotated[ + str, Query(description="Last UTC calendar day, YYYY-MM-DD, inclusive, at most 400 days after start") + ], + customer_org_ids: Annotated[ + str, + Query(description="Comma-separated IDs for up to 100 visible customer organizations"), + AfterValidator(bounded_organizations), + ], + model: str | None = None, + api_key: str | None = None, +) -> SpendAnalyticsPaginatedResponse: + date_error: Final = aggregated_date_range_error(start_date, end_date) + if date_error is not None: + raise_activity_error(InvalidDateRange(reason=date_error)) + ids: Final = customer_org_ids.split(",") + scope: Final = daily_activity_scope( + "litellm_dailycustomermemberspend", + CustomerEntity.ORGANIZATION.value, + ids, + None, + api_key, + start_date, + end_date, + model, + None, + ) + aliases: Final = await customer_aliases( + metadata_table(prisma_client, CustomerEntity.ORGANIZATION), CustomerEntity.ORGANIZATION, ids + ) + return await get_daily_activity_aggregated( + daily_activity_repository(prisma_client), + scope, + entity_metadata_field=aliases, + include_entity_breakdown=True, + api_key_limit=1, + ) + + @router.get( "/customer_organizations/daily/activity", response_model=SpendAnalyticsPaginatedResponse, diff --git a/litellm/proxy/management_endpoints/management_v1/customers.py b/litellm/proxy/management_endpoints/management_v1/customers.py index 438aa26f75b..e568d87d590 100644 --- a/litellm/proxy/management_endpoints/management_v1/customers.py +++ b/litellm/proxy/management_endpoints/management_v1/customers.py @@ -209,9 +209,10 @@ async def list_customer_organizations( hierarchy: Annotated[CustomerHierarchy, Depends(get_customer_hierarchy)], page: Annotated[int, Query(ge=1)] = 1, page_size: Annotated[int, Query(ge=1)] = 50, + q: Annotated[str, Query(description="Case-insensitive substring of the customer ID or alias")] = "", ) -> ListResponse[Organization]: size: Final = min(page_size, 100) - result: Final = resolve(await hierarchy.list_organizations(page, size)) + result: Final = resolve(await hierarchy.list_organizations(page, size, q.strip())) return list_response(result, request, page, size) diff --git a/litellm/proxy/management_helpers/customer_hierarchy.py b/litellm/proxy/management_helpers/customer_hierarchy.py index 1e89161025e..3ec37300127 100644 --- a/litellm/proxy/management_helpers/customer_hierarchy.py +++ b/litellm/proxy/management_helpers/customer_hierarchy.py @@ -7,9 +7,11 @@ from itertools import chain from typing import TYPE_CHECKING, Final, Generic, Protocol, TypeVar from uuid import uuid4 -from pydantic import ConfigDict, Field, TypeAdapter, model_validator +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator from litellm.models.budget import LiteLLM_BudgetTable +from litellm.proxy.db.db_span import db_span +from litellm.proxy.list_api.common import escape_like from litellm.repositories.budget_repository import create_budget_row, update_budget_row from litellm.repositories.chunked_in import find_many_in from litellm.repositories.prisma_protocols import TableActions @@ -83,7 +85,15 @@ class BudgetRecord(Budget): model_config = ConfigDict(frozen=True, extra="ignore") +class OrganizationMemberCount(BaseModel): + model_config = ConfigDict(frozen=True) + + customer_org_id: str + member_count: int + + _VALUES: Final = TypeAdapter(Mapping[str, object]) +_MEMBER_COUNTS: Final = TypeAdapter(tuple[OrganizationMemberCount, ...]) _BUDGET: Final[TypeAdapter[BudgetRecord | None]] = TypeAdapter(BudgetRecord | None) _EXPIRY: Final[TypeAdapter[datetime | None]] = TypeAdapter(datetime | None) @@ -131,10 +141,13 @@ class StoredBudget(BudgetRecord): budget_id: str -def organization_response(row: OrganizationRecord, budgets: Mapping[str, StoredBudget]) -> Organization: +def organization_response( + row: OrganizationRecord, budgets: Mapping[str, StoredBudget], member_count: int +) -> Organization: return Organization.model_validate( { **row.model_dump(exclude={"budget_ids", "budget_names"}), + "member_count": member_count, "default_budgets": tuple( OrganizationBudget.model_validate( { @@ -225,7 +238,7 @@ class CustomerHierarchyStore: async def organization_responses(self, rows: Sequence[PrismaRecord]) -> tuple[Organization, ...]: records: Final = tuple( OrganizationRecord.model_validate( - {**row.model_dump(), "budget": related_budget(row.model_dump().get("budget"))} + {**row.model_dump(), "budget": related_budget(row.model_dump().get("budget"), self.now)} ) for row in rows ) @@ -233,7 +246,26 @@ class CustomerHierarchyStore: budget_rows: Final = await find_many_in(self.budgets, "budget_id", budget_ids) resolved: Final = tuple(StoredBudget.model_validate(row.model_dump()) for row in budget_rows) budgets: Final = {budget.budget_id: budget for budget in resolved} - return tuple(organization_response(record, budgets) for record in records) + + counts: Final = await self.organization_member_counts(tuple(record.customer_org_id for record in records)) + return tuple( + organization_response(record, budgets, counts.get(record.customer_org_id, 0)) for record in records + ) + + async def organization_member_counts(self, organization_ids: tuple[str, ...]) -> Mapping[str, int]: + if not organization_ids: + return {} + async with db_span("find_many", "LiteLLM_CustomerTeamTable"): + rows: Final = _MEMBER_COUNTS.validate_python( + await self.db.query_raw( + "SELECT t.parent_org_id AS customer_org_id, COUNT(m.customer_member_id) AS member_count " + 'FROM "LiteLLM_CustomerTeamTable" t ' + 'LEFT JOIN "LiteLLM_CustomerMemberTable" m ON m.parent_team_id = t.customer_team_id ' + "WHERE t.parent_org_id = ANY($1::text[]) GROUP BY t.parent_org_id", + list(organization_ids), + ) + ) + return {row.customer_org_id: row.member_count for row in rows} async def get_organization(self, organization_id: str) -> Organization | Failure: row: Final = await self.organizations.find_unique( @@ -527,9 +559,20 @@ class CustomerHierarchyStore: await self.delete_released_budget(member.budget_id, budget_id) return await self.get_member(member_id) - async def list_organizations(self, page: int, page_size: int) -> Page[Organization]: - count: Final = await self.organizations.count() + async def list_organizations(self, page: int, page_size: int, search: str = "") -> Page[Organization]: + where: Final = ( + { + "OR": tuple( + {field: {"contains": escape_like(search), "mode": "insensitive"}} + for field in ("alias", "customer_org_id") + ) + } + if search + else {} + ) + count: Final = await self.organizations.count(where=where) rows: Final = await self.organizations.find_many( + where=where, skip=(page - 1) * page_size, take=page_size, order=[{"created_at": "asc"}, {"customer_org_id": "asc"}], @@ -664,8 +707,10 @@ class CustomerHierarchy: lambda store: store.delete_member(entity_id), operation_type=TransactionOperation.DELETE ) - async def list_organizations(self, page: int = 1, page_size: int = 50) -> Page[Organization] | Failure: - return await self.transaction(lambda store: store.list_organizations(page, page_size)) + async def list_organizations( + self, page: int = 1, page_size: int = 50, search: str = "" + ) -> Page[Organization] | Failure: + return await self.transaction(lambda store: store.list_organizations(page, page_size, search)) async def list_teams( self, parent_org_id: str | None = None, page: int = 1, page_size: int = 50 diff --git a/litellm/types/proxy/management_endpoints/customer_hierarchy.py b/litellm/types/proxy/management_endpoints/customer_hierarchy.py index 5866630be12..aca4c1626df 100644 --- a/litellm/types/proxy/management_endpoints/customer_hierarchy.py +++ b/litellm/types/proxy/management_endpoints/customer_hierarchy.py @@ -90,6 +90,7 @@ class AuditFields(BaseModel): class Organization(AuditFields): + member_count: int = Field(default=0, ge=0, description="Current registered members across all customer teams") budget_id: str | None = None budget: Budget | None = None customer_org_id: str diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx index c645c180e53..8ad86638fcf 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx @@ -15,36 +15,36 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { buildCreateCustomerBody, digitsOnly, decimalOnly } from "./customerForms"; interface Props { open: boolean; onOpenChange: (o: boolean) => void; - onCreated: (userId: string) => void; + onCreated: (customerOrgId: string) => void; } const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); export default function CreateCustomerDialog({ open, onOpenChange, onCreated }: Props) { const { t } = useTranslation(); - const [userId, setUserId] = useState(""); + const [customerOrgId, setCustomerOrgId] = useState(""); const [alias, setAlias] = useState(""); - const [budget, setBudget] = useState(""); - const [tpm, setTpm] = useState(""); - const [rpm, setRpm] = useState(""); - const create = $api.useMutation("post", "/customer/new", { - onSuccess: () => { - toast.success(t("customerPortal.toasts.created", { name: alias.trim() || userId.trim() })); - onCreated(userId.trim()); + const create = $api.useMutation("post", "/management/v1/customer_organizations", { + onSuccess: (customer) => { + toast.success(t("customerPortal.toasts.created", { name: alias.trim() || customerOrgId.trim() })); + onCreated(customer.customer_org_id); + setCustomerOrgId(""); + setAlias(""); onOpenChange(false); }, onError: (e: unknown) => toast.error(t("customerPortal.toasts.createFailed", { error: errorText(e) })), }); const submit = (e: React.FormEvent) => { e.preventDefault(); - const fields = { userId, alias, budget, tpm, rpm }; - const body = buildCreateCustomerBody(fields); - if (body) create.mutate({ body }); + if (customerOrgId.trim()) { + create.mutate({ + body: { customer_org_id: customerOrgId.trim(), alias: alias.trim() || null, default_budgets: [] }, + }); + } }; return ( @@ -55,11 +55,11 @@ export default function CreateCustomerDialog({ open, onOpenChange, onCreated }: {t("customerPortal.createDialog.description")}
- + setUserId(e.target.value)} + id="cc-org-id" + value={customerOrgId} + onChange={(e) => setCustomerOrgId(e.target.value)} placeholder={t("customerPortal.createDialog.customerIdPlaceholder")} autoFocus required @@ -74,44 +74,11 @@ export default function CreateCustomerDialog({ open, onOpenChange, onCreated }: placeholder={t("customerPortal.createDialog.aliasPlaceholder")} />
-
- - setBudget(decimalOnly(e.target.value))} - placeholder={t("customerPortal.createDialog.maxBudgetPlaceholder")} - /> -
-
-
- - setTpm(digitsOnly(e.target.value))} - placeholder={t("customerPortal.createDialog.noLimit")} - /> -
-
- - setRpm(digitsOnly(e.target.value))} - placeholder={t("customerPortal.createDialog.noLimit")} - /> -
-
-

{t("customerPortal.createDialog.limitsHelp")}

- diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageRange.test.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageRange.test.ts new file mode 100644 index 00000000000..78401716536 --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageRange.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { customerPresetRange, customerRangeParser } from "./customerUsageRange"; + +describe("customer usage calendar ranges", () => { + it.each([ + ["month", "2024-02-15T12:00:00Z", "2024-02-01..2024-02-29"], + ["previous", "2025-01-01T00:00:00Z", "2024-12-01..2024-12-31"], + ["YTD", "2025-01-01T00:00:00Z", "2025-01-01..2025-01-01"], + ["YTD", "2025-11-12T00:01:00Z", "2025-01-01..2025-11-12"], + ] as const)("resolves %s at %s as UTC calendar days", (preset, now, expected) => { + expect(customerRangeParser.serialize(customerPresetRange(preset, new Date(now)))).toBe(expected); + }); + + it("round trips a single calendar day without shifting its timezone", () => { + const range = customerRangeParser.parse("2024-02-29..2024-02-29"); + expect(range).not.toBeNull(); + expect(customerRangeParser.serialize(range!)).toBe("2024-02-29..2024-02-29"); + }); + + it("accepts the API's 400-day boundary across leap years and daylight saving", () => { + const range = customerRangeParser.parse("2024-01-01..2025-02-04"); + expect(range).not.toBeNull(); + expect(customerRangeParser.serialize(range!)).toBe("2024-01-01..2025-02-04"); + }); + + it.each([ + "2023-02-29..2023-03-01", + "2024-02-30..2024-03-01", + "2024-03-01..2024-02-01", + "2024-02-01", + "2024-02-01..2024-02-29..2024-03-01", + "2024-2-01..2024-02-29", + "2024-01-01..2025-02-05", + ])("rejects invalid URL range %s", (range) => { + expect(customerRangeParser.parse(range)).toBeNull(); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageRange.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageRange.ts new file mode 100644 index 00000000000..df89f8d63ce --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageRange.ts @@ -0,0 +1,81 @@ +import { createParser, parseAsStringLiteral, useQueryStates } from "nuqs"; +import { useMemo } from "react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; +import type { RelativeTimeOption } from "@/components/shared/date_range_presets"; + +const PRESETS = ["month", "previous", "YTD"] as const; +type Preset = (typeof PRESETS)[number]; +type Range = { from: Date; to: Date }; + +export const CUSTOMER_USAGE_MAX_RANGE_DAYS = 400; + +export const calendarDate = (date: Date): string => + `${date.getFullYear().toString().padStart(4, "0")}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`; + +const parseDate = (value: string): Date | null => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; + const date = new Date(`${value}T12:00:00`); + return Number.isFinite(date.getTime()) && calendarDate(date) === value ? date : null; +}; + +export const customerRangeParser = createParser({ + parse: (value) => { + const [start, end, extra] = value.split(".."); + if (!start || !end || extra !== undefined) return null; + const from = parseDate(start); + const to = parseDate(end); + if (!from || !to || from > to) return null; + const spanDays = (Date.parse(end) - Date.parse(start)) / 86_400_000; + return spanDays <= CUSTOMER_USAGE_MAX_RANGE_DAYS ? { from, to } : null; + }, + serialize: ({ from, to }) => `${calendarDate(from)}..${calendarDate(to)}`, + eq: (a, b) => calendarDate(a.from) === calendarDate(b.from) && calendarDate(a.to) === calendarDate(b.to), +}); + +export const customerPresetRange = (preset: Preset, now = new Date()): Range => { + const year = now.getUTCFullYear(); + const month = now.getUTCMonth(); + if (preset === "YTD") { + return { from: new Date(year, 0, 1), to: new Date(year, month, now.getUTCDate(), 23, 59, 59) }; + } + const targetMonth = preset === "previous" ? month - 1 : month; + return { from: new Date(year, targetMonth, 1), to: new Date(year, targetMonth + 1, 0, 23, 59, 59) }; +}; + +export const customerUsagePresets: readonly RelativeTimeOption[] = [ + { + label: "customerPortal.range.thisMonth", + shortLabel: "month", + settingValue: "this_month", + getValue: () => customerPresetRange("month"), + }, + { + label: "customerPortal.range.previousMonth", + shortLabel: "previous", + settingValue: "previous_month", + getValue: () => customerPresetRange("previous"), + }, + { + label: "shared.dateRangePresets.yearToDate", + shortLabel: "YTD", + settingValue: "year_to_date", + getValue: () => customerPresetRange("YTD"), + }, +]; + +const presetParser = parseAsStringLiteral(PRESETS); +const parsers = { range: customerRangeParser, period: presetParser }; + +export const useCustomerUsageRange = () => { + const [params, setParams] = useQueryStates(parsers); + const initialRange = useMemo(() => customerPresetRange("month"), []); + const dateValue = params.range ?? initialRange; + const onDateChange = (value: DateRangePickerValue, preset: string | null) => { + if (!value.from || !value.to || value.from > value.to) return; + void setParams( + { range: { from: value.from, to: value.to }, period: preset ? presetParser.parse(preset) : null }, + { history: "push" }, + ); + }; + return { dateValue, onDateChange, preset: params.range ? params.period : "month" }; +}; diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageTotals.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageTotals.ts new file mode 100644 index 00000000000..6a4eecfbefa --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageTotals.ts @@ -0,0 +1,17 @@ +import type { components } from "@/lib/http/schema"; + +type Totals = Readonly<{ spend: number; requests: number; tokens: number }>; + +export function customerUsageTotals(days: readonly components["schemas"]["DailySpendData"][]): Record { + return days.reduce>((totals, day) => { + for (const [id, entity] of Object.entries(day.breakdown?.entities ?? {})) { + const previous = totals[id]; + totals[id] = { + spend: (previous?.spend ?? 0) + entity.metrics.spend, + requests: (previous?.requests ?? 0) + entity.metrics.api_requests, + tokens: (previous?.tokens ?? 0) + entity.metrics.total_tokens, + }; + } + return totals; + }, {}); +} diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/page.integration.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/page.integration.test.tsx new file mode 100644 index 00000000000..87bbfb7143d --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/page.integration.test.tsx @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { fireEvent, renderWithProviders, screen, testQueryClient, waitFor, within } from "../../../../tests/test-utils"; +import CustomersPage from "./page"; + +vi.unmock("@/app/(dashboard)/hooks/useAuthorized"); + +const NOW = new Date("2024-03-15T12:00:00Z"); +const requests: URL[] = []; +const listRequests: URL[] = []; +const creations: unknown[] = []; +let failed = false; + +const handleRequest = async (request: Request): Promise => { + const url = new URL(request.url); + if (url.pathname.endsWith("/.well-known/litellm-ui-config")) { + return Response.json({ server_root_path: "", proxy_base_url: "", admin_ui_disabled: false }); + } + if (url.pathname === "/management/v1/users/me") { + return Response.json({ user_id: "admin", user_role: "proxy_admin", user_email: null }); + } + if (url.pathname === "/management/v1/customer_organizations" && request.method === "GET") { + listRequests.push(url); + return Response.json({ + data: [ + { customer_org_id: "customer-a", alias: "Customer A", spend: 51, budget: { max_budget: 500 }, member_count: 3 }, + { customer_org_id: "over-budget", spend: 750, budget: { max_budget: 500 }, member_count: 2 }, + { customer_org_id: "zero-budget", spend: 1, budget: { max_budget: 0 }, member_count: 1 }, + { customer_org_id: "no-budget", spend: 0, budget: null, member_count: 0 }, + { customer_org_id: "unused-budget", spend: 0, budget: { max_budget: 500 }, member_count: 4 }, + ], + meta: { total_count: 30, page: Number(url.searchParams.get("page")), page_size: 25, total_pages: 2 }, + links: {}, + }); + } + if (url.pathname === "/management/v1/customer_organizations/daily/activity/aggregated") { + requests.push(url); + if (failed) return Response.json({ detail: "Unavailable" }, { status: 503 }); + const previous = url.searchParams.get("start_date") === "2024-02-01"; + return Response.json({ + results: [ + { + date: previous ? "2024-02-01" : "2024-03-01", + breakdown: { + entities: { + "customer-a": { metrics: { spend: previous ? 3 : 15, api_requests: 10, total_tokens: 300 } }, + }, + }, + }, + { + date: previous ? "2024-02-15" : "2024-03-15", + breakdown: { + entities: { + "customer-a": { + metrics: { + spend: previous ? 4 : 36, + api_requests: previous ? 10 : 30, + total_tokens: previous ? 100 : 500, + }, + }, + }, + }, + }, + ], + metadata: { has_more: false, total_pages: 1 }, + }); + } + if (url.pathname === "/management/v1/customer_organizations" && request.method === "POST") { + const body: unknown = await request.json(); + creations.push(body); + return Response.json({ customer_org_id: "new-org" }, { status: 201 }); + } + throw new Error(`Unexpected request ${request.method} ${url.pathname}`); +}; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + testQueryClient.clear(); + requests.length = 0; + listRequests.length = 0; + creations.length = 0; + failed = false; + const claims = { key: "sk-test", user_id: "admin", user_role: "proxy_admin", exp: NOW.getTime() / 1000 + 3600 }; + document.cookie = `token=${btoa("{}")}.${btoa(JSON.stringify(claims))}.test; Path=/`; + vi.stubGlobal("fetch", vi.fn(handleRequest)); + vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => + window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 0), + ); +}); + +afterEach(() => { + testQueryClient.clear(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + document.cookie = "token=; Max-Age=0; Path=/"; +}); + +describe("customer organization usage", () => { + it("changes range totals in one aggregate request while retaining current budget usage", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams: "?page=2", onUrlUpdate }); + const row = await screen.findByRole("row", { name: /Customer A/ }); + expect(within(row).getByRole("cell", { name: "$51.00 / $500.00 10.2% used" })).toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: "Budget usage" })).not.toBeInTheDocument(); + expect(within(row).getByText("3")).toBeInTheDocument(); + expect(within(row).getByText("40")).toBeInTheDocument(); + expect(within(row).getByText("800")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /1 Mar 2024/ })); + await user.click(screen.getByRole("button", { name: /Previous month/ })); + await user.click(screen.getByRole("button", { name: "Apply" })); + expect(await screen.findByText("$7.00")).toBeInTheDocument(); + const updatedRow = screen.getByRole("row", { name: /Customer A/ }); + expect( + within(updatedRow).getByRole("cell", { name: "$7.00 Current budget $51.00 / $500.00 10.2% used" }), + ).toBeInTheDocument(); + expect(within(updatedRow).getByText("3")).toBeInTheDocument(); + expect(within(updatedRow).getByRole("link", { name: "Customer A" })).toHaveAttribute( + "href", + "/ui/customers/usage?org=customer-a&from=2024-02-01&to=2024-02-29", + ); + expect(requests.at(-1)?.searchParams.get("start_date")).toBe("2024-02-01"); + expect(requests.at(-1)?.searchParams.get("end_date")).toBe("2024-02-29"); + expect(listRequests.at(-1)?.searchParams.get("page")).toBe("1"); + expect(requests).toHaveLength(2); + expect(requests.at(-1)?.searchParams.get("customer_org_ids")).toBe( + "customer-a,over-budget,zero-budget,no-budget,unused-budget", + ); + expect(requests.at(-1)?.searchParams.has("page")).toBe(false); + expect(onUrlUpdate.mock.lastCall?.[0].queryString).toContain("range=2024-02-01..2024-02-29"); + }); + + it("restores a custom range from the URL and sends edited calendar days", async () => { + const user = userEvent.setup(); + renderWithProviders(, { searchParams: "?range=2024-01-03..2024-01-10" }); + await screen.findByRole("row", { name: /Customer A/ }); + expect(requests.at(-1)?.searchParams.get("start_date")).toBe("2024-01-03"); + expect(requests.at(-1)?.searchParams.get("end_date")).toBe("2024-01-10"); + await user.click(screen.getByRole("button", { name: /3 Jan 2024/ })); + fireEvent.change(screen.getByLabelText("Start date"), { target: { value: "2024-02-01" } }); + fireEvent.change(screen.getByLabelText("End date"), { target: { value: "2024-02-29" } }); + await user.click(screen.getByRole("button", { name: "Apply" })); + expect(await screen.findByText("$7.00")).toBeInTheDocument(); + expect(requests.at(-1)?.searchParams.get("end_date")).toBe("2024-02-29"); + }); + + it("shows over-budget percentages, zero and missing limits, and members without usage", async () => { + renderWithProviders(); + const over = await screen.findByRole("row", { name: /over-budget/ }); + expect(within(over).getByText("Current budget $750.00 / $500.00")).toBeInTheDocument(); + expect(within(over).getByText("150% used")).toBeInTheDocument(); + const zero = screen.getByRole("row", { name: /zero-budget/ }); + expect(within(zero).getByText("Current budget $1.00 / $0.00")).toBeInTheDocument(); + expect(within(zero).getByText("Percentage unavailable for a $0 budget")).toBeInTheDocument(); + const uncapped = screen.getByRole("row", { name: /no-budget/ }); + expect(within(uncapped).getByText("$0.00")).toBeInTheDocument(); + expect(within(uncapped).getByText("No max budget")).toBeInTheDocument(); + const unused = screen.getByRole("row", { name: /unused-budget/ }); + expect(within(unused).getByText("$0.00 / $500.00")).toBeInTheDocument(); + expect(within(unused).getByText("0% used")).toBeInTheDocument(); + expect(within(unused).getByText("4")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Members" })).toBeInTheDocument(); + }); + + it("blocks ranges over 400 days before Apply and accepts the boundary", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByRole("row", { name: /Customer A/ }); + await user.click(screen.getByRole("button", { name: /1 Mar 2024/ })); + fireEvent.change(screen.getByLabelText("Start date"), { target: { value: "2024-01-01" } }); + fireEvent.change(screen.getByLabelText("End date"), { target: { value: "2025-02-05" } }); + expect(screen.getByText("Select a date range of 400 days or less.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + expect(requests).toHaveLength(1); + fireEvent.change(screen.getByLabelText("End date"), { target: { value: "2025-02-04" } }); + expect(screen.getByRole("button", { name: "Apply" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests.at(-1)?.searchParams.get("end_date")).toBe("2025-02-04"); + }); + + it("uses the default month when a saved URL exceeds the range limit", async () => { + renderWithProviders(, { searchParams: "?range=2020-01-01..2024-03-31" }); + await screen.findByRole("row", { name: /Customer A/ }); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get("start_date")).toBe("2024-03-01"); + expect(requests[0].searchParams.get("end_date")).toBe("2024-03-31"); + }); + + it("shows a failure instead of presenting missing usage as zero", async () => { + failed = true; + renderWithProviders(); + expect(await screen.findByText("Could not load customer organization usage.")).toBeInTheDocument(); + expect(screen.queryByRole("row", { name: /Customer A/ })).not.toBeInTheDocument(); + }); + + it("creates an organization using the entered ID and alias", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "Create Customer" })); + fireEvent.change(screen.getByLabelText("Customer organization id"), { target: { value: " new-org " } }); + fireEvent.change(screen.getByLabelText("Alias"), { target: { value: " New customer " } }); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Create" })); + await waitFor(() => + expect(creations).toEqual([{ customer_org_id: "new-org", alias: "New customer", default_budgets: [] }]), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx index f80d15f5acf..ffc460eb892 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx @@ -1,152 +1,243 @@ "use client"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { parseAsString, useQueryState } from "nuqs"; import { Plus } from "lucide-react"; -import { toast } from "sonner"; +import { createSerializer, parseAsString, useQueryState } from "nuqs"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { useCustomers, type EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers"; import { $api } from "@/lib/http/api"; +import { customerUsageTotals } from "./_components/customerUsageTotals"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import CustomerDetail from "./_components/CustomerDetail"; +import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; +import { DataTablePagination, useUrlTableState } from "@/components/shared/DataTable"; +import { ErrorState } from "@/components/shared/ErrorState"; +import { uiHref } from "@/utils/uiHref"; import CreateCustomerDialog from "./_components/CreateCustomerDialog"; -import StatusBadge from "./_components/StatusBadge"; -import { rankBySpend, useCustomerUsage } from "./_components/customerPortalData"; +import CustomerDetail from "./_components/CustomerDetail"; +import { + calendarDate, + CUSTOMER_USAGE_MAX_RANGE_DAYS, + customerUsagePresets, + useCustomerUsageRange, +} from "./_components/customerUsageRange"; + +const TABLE_OPTIONS = { + sortFields: ["created_at"], + defaultSort: { id: "created_at", desc: false }, + defaultPageSize: 25, + filterColumns: [], +}; + +const usageHref = createSerializer({ org: parseAsString, from: parseAsString, to: parseAsString }); export default function CustomersPage() { + const [selectedId, setSelectedId] = useQueryState("id", parseAsString); + if (selectedId) return void setSelectedId(null)} />; + return ; +} + +function CustomerSpend({ + spend, + budgetSpend, + maxBudget, +}: { + spend: number; + budgetSpend: number; + maxBudget: number | null; +}) { + const { t } = useTranslation(); + const spendLabel = t("format.usd", { val: spend }); + if (maxBudget === null) { + return ( + <> +

{spendLabel}

+

{t("customerPortal.list.noMaxBudget")}

+ + ); + } + const percentage = + maxBudget > 0 + ? t("customerPortal.list.budgetUsed", { + percentage: t("format.number", { + val: budgetSpend / maxBudget, + style: "percent", + maximumFractionDigits: 1, + }), + }) + : t("customerPortal.list.zeroBudgetPercentage"); + const budgetSpendLabel = t("format.usd", { val: budgetSpend }); + const budgetLabel = t("customerPortal.list.budgetUsage", { + spend: budgetSpendLabel, + max: t("format.usd", { val: maxBudget }), + }); + const matchesBudgetSpend = spendLabel === budgetSpendLabel; + return ( + <> +

{matchesBudgetSpend ? budgetLabel : spendLabel}

+ {!matchesBudgetSpend && ( +

+ {t("customerPortal.list.currentBudget", { budget: budgetLabel })} +

+ )} +

{percentage}

+ + ); +} + +function CustomerOrganizations() { const { t } = useTranslation(); const { accessToken } = useAuthorized(); - const [selectedId, setSelectedId] = useQueryState("id", parseAsString); - const [search, setSearch] = useState(""); const [createOpen, setCreateOpen] = useState(false); - const customersQuery = useCustomers(100, search || undefined); - const usageQuery = useCustomerUsage(accessToken); - const customers: EndUser[] = useMemo(() => customersQuery.data?.pages.flat() ?? [], [customersQuery.data]); - const rows = useMemo(() => rankBySpend(customers, usageQuery.data), [customers, usageQuery.data]); - const block = $api.useMutation("post", "/customer/block", { - onSuccess: () => { - toast.success(t("customerPortal.toasts.blocked")); - customersQuery.refetch(); + const { dateValue, onDateChange, preset } = useCustomerUsageRange(); + const { search, setSearch, pagination, onPaginationChange } = useUrlTableState(TABLE_OPTIONS); + const organizations = $api.useQuery( + "get", + "/management/v1/customer_organizations", + { + params: { + query: { q: search, page: pagination.pageIndex + 1, page_size: pagination.pageSize }, + }, }, - }); - const unblock = $api.useMutation("post", "/customer/update", { - onSuccess: () => { - toast.success(t("customerPortal.toasts.unblocked")); - customersQuery.refetch(); + { enabled: Boolean(accessToken) }, + ); + const rows = organizations.data?.data ?? []; + const usage = $api.useQuery( + "get", + "/management/v1/customer_organizations/daily/activity/aggregated", + { + params: { + query: { + start_date: calendarDate(dateValue.from), + end_date: calendarDate(dateValue.to), + customer_org_ids: rows.map((customer) => customer.customer_org_id).join(","), + }, + }, }, - }); - - if (selectedId) return setSelectedId(null)} />; + { enabled: Boolean(accessToken) && rows.length > 0 }, + ); + const loading = organizations.isPending || (rows.length > 0 && usage.isPending); + const totals = customerUsageTotals(usage.data?.results ?? []); return (
-
+

{t("customerPortal.list.title")}

-

{t("customerPortal.list.subtitle")}

+

{t("customerPortal.list.subtitle")}

-

{t("customerPortal.common.blockHint")}

-
+
setSearch(e.target.value)} + onChange={(event) => setSearch(event.target.value)} + /> + { + onDateChange(value, selectedPreset); + onPaginationChange({ ...pagination, pageIndex: 0 }); + }} + presetShortLabel={preset} + presets={customerUsagePresets} + maxRangeDays={CUSTOMER_USAGE_MAX_RANGE_DAYS} + label={t("customerPortal.range.label")} + showTimeRange={false} + compact + className="max-w-sm" />
- - - - {t("customerPortal.list.columns.customer")} - {t("customerPortal.list.columns.spend")} - {t("customerPortal.list.columns.budget")} - {t("customerPortal.list.columns.keys")} - {t("customerPortal.list.columns.status")} - - - - - {customersQuery.isLoading && ( + {organizations.isError || usage.isError ? ( + void (organizations.isError ? organizations.refetch() : usage.refetch())} + /> + ) : ( +
+ - - {t("customerPortal.list.loading")} - + {t("customerPortal.list.columns.customer")} + {t("customerPortal.list.columns.spend")} + {t("customerPortal.list.columns.requests")} + {t("customerPortal.list.columns.tokens")} + {t("customerPortal.list.columns.members")} - )} - {!customersQuery.isLoading && rows.length === 0 && ( - - - {t("customerPortal.list.empty")} - - - )} - {rows.map(({ c, spend, keys, status }) => { - const max = c.litellm_budget_table?.max_budget ?? null; - return ( - setSelectedId(c.user_id, { history: "push" })} - > - -

{c.alias || c.user_id}

-

{c.user_id}

-
- - {t("format.usd", { val: spend })} - - - {max === null - ? t("customerPortal.common.noBudget") - : t("customerPortal.list.budgetUsage", { - spend: t("format.usd", { val: spend }), - max: t("format.usd", { val: max }), - })} - - {keys} - - - - e.stopPropagation()}> - {c.blocked ? ( - - ) : ( - - )} +
+ + {loading && ( + + + {t("customerPortal.list.loading")} - ); - })} - -
+ )} + {!loading && rows.length === 0 && ( + + + {t(search ? "customerPortal.list.noMatches" : "customerPortal.list.empty")} + + + )} + {!loading && + rows.map((customer) => ( + + + + {customer.alias || customer.customer_org_id} + +

{customer.customer_org_id}

+
+ + + + + {t("format.number", { val: totals[customer.customer_org_id]?.requests ?? 0 })} + + + {t("format.number", { val: totals[customer.customer_org_id]?.tokens ?? 0 })} + + + {t("format.number", { val: customer.member_count })} + +
+ ))} + + + )} + onPaginationChange({ ...pagination, pageIndex })} + onPageSizeChange={(pageSize) => onPaginationChange({ pageSize, pageIndex: 0 })} + isLoading={organizations.isFetching || usage.isFetching} + />
+

{t("customerPortal.list.budgetAndMembersHelp")}

+

{t("customerPortal.list.attribution")}

{ - customersQuery.refetch(); - setSelectedId(id, { history: "push" }); - }} + onCreated={() => void organizations.refetch()} />
); diff --git a/litellm/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/litellm/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx index 2a0f83fc262..25d2aca7d6e 100644 --- a/litellm/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/litellm/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -19,12 +19,17 @@ interface AdvancedDatePickerProps { showTimeRange?: boolean; align?: "left" | "right"; compact?: boolean; + presets?: readonly RelativeTimeOption[]; + maxRangeDays?: number; } -export function matchRelativeTimeOption(value: DateRangePickerValue): RelativeTimeOption | undefined { +export function matchRelativeTimeOption( + value: DateRangePickerValue, + presets: readonly RelativeTimeOption[] = relativeTimeOptions, +): RelativeTimeOption | undefined { const { from, to } = value; if (!from || !to) return undefined; - return relativeTimeOptions.find((option) => { + return presets.find((option) => { const optionRange = option.getValue(); return moment(from).isSame(optionRange.from, "day") && moment(to).isSame(optionRange.to, "day"); }); @@ -42,6 +47,8 @@ const AdvancedDatePicker: React.FC = ({ showTimeRange = true, align = "right", compact = false, + presets = relativeTimeOptions, + maxRangeDays, }) => { const { t } = useTranslation(); const inputId = useId(); @@ -57,8 +64,9 @@ const AdvancedDatePicker: React.FC = ({ const dropdownRef = useRef(null); const getMatchingOption = useCallback( - (currentValue: DateRangePickerValue): string | null => matchRelativeTimeOption(currentValue)?.shortLabel ?? null, - [], + (currentValue: DateRangePickerValue): string | null => + matchRelativeTimeOption(currentValue, presets)?.shortLabel ?? null, + [presets], ); // Update selected option when value changes @@ -70,7 +78,7 @@ const AdvancedDatePicker: React.FC = ({ // Validation logic - simplified for dates only const validateDateRange = useCallback(() => { if (!startDate || !endDate) { - return { isValid: true, error: "" }; + return { isValid: showTimeRange, error: "" }; } const start = moment(startDate, "YYYY-MM-DD"); @@ -84,8 +92,12 @@ const AdvancedDatePicker: React.FC = ({ return { isValid: false, error: t("shared.advancedDatePicker.endBeforeStart") }; } + if (maxRangeDays !== undefined && end.diff(start, "days") > maxRangeDays) { + return { isValid: false, error: t("shared.advancedDatePicker.rangeTooLong", { days: maxRangeDays }) }; + } + return { isValid: true, error: "" }; - }, [startDate, endDate, t]); + }, [startDate, endDate, showTimeRange, maxRangeDays, t]); const validation = validateDateRange(); @@ -123,12 +135,12 @@ const AdvancedDatePicker: React.FC = ({ if (!from || !to) return t("shared.advancedDatePicker.selectDateRange"); const formatDateTime = (date: Date) => { - return moment(date).format("D MMM, HH:mm"); + return moment(date).format(showTimeRange ? "D MMM, HH:mm" : "D MMM YYYY"); }; return `${formatDateTime(from)} - ${formatDateTime(to)}`; }, - [t], + [t, showTimeRange], ); // CRITICAL: Apply the same date adjustment logic as the original component @@ -205,6 +217,11 @@ const AdvancedDatePicker: React.FC = ({ const handleApply = () => { if (tempValue.from && tempValue.to && validation.isValid) { + if (!showTimeRange) { + onValueChange(adjustDateRange(tempValue), pickedPreset); + setIsOpen(false); + return; + } // First call with immediate value for UI responsiveness onValueChange(tempValue, pickedPreset); @@ -296,7 +313,7 @@ const AdvancedDatePicker: React.FC = ({
- {relativeTimeOptions.map((option) => { + {presets.map((option) => { const isSelected = selectedOption === option.shortLabel; return (