feat(ui): show customer organization usage and budget utilization (#51483)

* feat(ui): show customer organization usage by date range

* feat(customers): align usage reads with daily spend contract

* fix(customers): combine spend display and resolve CI failures

* test(vector-stores): isolate search warnings from SDK verbosity

* fix(ui): cap customer usage date ranges at 400 days
This commit is contained in:
tin-berri 2026-11-12 17:25:41 -08:00 • committed by GitHub
parent 07fe19bb15
commit 9633be87ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1011 additions and 216 deletions

View file

@ -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",

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -55,11 +55,11 @@ export default function CreateCustomerDialog({ open, onOpenChange, onCreated }:
<DialogDescription>{t("customerPortal.createDialog.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label htmlFor="cc-user-id">{t("customerPortal.createDialog.customerId")}</Label>
<Label htmlFor="cc-org-id">{t("customerPortal.createDialog.customerId")}</Label>
<Input
id="cc-user-id"
value={userId}
onChange={(e) => 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")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cc-budget">{t("customerPortal.createDialog.maxBudget")}</Label>
<Input
id="cc-budget"
inputMode="decimal"
value={budget}
onChange={(e) => setBudget(decimalOnly(e.target.value))}
placeholder={t("customerPortal.createDialog.maxBudgetPlaceholder")}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="cc-tpm">{t("customerPortal.createDialog.tpm")}</Label>
<Input
id="cc-tpm"
inputMode="numeric"
value={tpm}
onChange={(e) => setTpm(digitsOnly(e.target.value))}
placeholder={t("customerPortal.createDialog.noLimit")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cc-rpm">{t("customerPortal.createDialog.rpm")}</Label>
<Input
id="cc-rpm"
inputMode="numeric"
value={rpm}
onChange={(e) => setRpm(digitsOnly(e.target.value))}
placeholder={t("customerPortal.createDialog.noLimit")}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t("customerPortal.createDialog.limitsHelp")}</p>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("customerPortal.common.cancel")}
</Button>
<Button type="submit" disabled={!userId.trim() || create.isPending}>
<Button type="submit" disabled={!customerOrgId.trim() || create.isPending}>
{t("customerPortal.createDialog.submit")}
</Button>
</DialogFooter>

View file

@ -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();
});
});

View file

@ -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<Range>({
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" };
};

View file

@ -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<string, Totals> {
return days.reduce<Record<string, Totals>>((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;
}, {});
}

View file

@ -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<Response> => {
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(<CustomersPage />, { 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(<CustomersPage />, { 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(<CustomersPage />);
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(<CustomersPage />);
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(<CustomersPage />, { 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(<CustomersPage />);
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(<CustomersPage />);
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());
});
});

View file

@ -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 <CustomerDetail customerId={selectedId} onBack={() => void setSelectedId(null)} />;
return <CustomerOrganizations />;
}
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 (
<>
<p className="font-medium">{spendLabel}</p>
<p className="text-xs text-muted-foreground">{t("customerPortal.list.noMaxBudget")}</p>
</>
);
}
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 (
<>
<p className="whitespace-nowrap font-medium">{matchesBudgetSpend ? budgetLabel : spendLabel}</p>
{!matchesBudgetSpend && (
<p className="text-xs text-muted-foreground">
{t("customerPortal.list.currentBudget", { budget: budgetLabel })}
</p>
)}
<p className="text-xs text-muted-foreground">{percentage}</p>
</>
);
}
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 <CustomerDetail customerId={selectedId} onBack={() => setSelectedId(null)} />;
{ enabled: Boolean(accessToken) && rows.length > 0 },
);
const loading = organizations.isPending || (rows.length > 0 && usage.isPending);
const totals = customerUsageTotals(usage.data?.results ?? []);
return (
<div className="w-full space-y-4 px-6 py-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-baseline gap-3">
<div className="min-w-0 space-y-1">
<h1 className="text-lg font-semibold tracking-tight">{t("customerPortal.list.title")}</h1>
<p className="truncate text-sm text-muted-foreground">{t("customerPortal.list.subtitle")}</p>
<p className="text-sm text-muted-foreground">{t("customerPortal.list.subtitle")}</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" /> {t("customerPortal.list.create")}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("customerPortal.common.blockHint")}</p>
<div className="rounded-lg border bg-card">
<div className="border-b p-3">
<div className="flex flex-wrap items-end justify-between gap-3 border-b p-3">
<Input
className="max-w-xs"
aria-label={t("customerPortal.list.searchPlaceholder")}
placeholder={t("customerPortal.list.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(event) => setSearch(event.target.value)}
/>
<AdvancedDatePicker
value={dateValue}
onValueChange={(value, selectedPreset) => {
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"
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-4">{t("customerPortal.list.columns.customer")}</TableHead>
<TableHead className="text-right">{t("customerPortal.list.columns.spend")}</TableHead>
<TableHead className="text-right">{t("customerPortal.list.columns.budget")}</TableHead>
<TableHead className="text-right">{t("customerPortal.list.columns.keys")}</TableHead>
<TableHead>{t("customerPortal.list.columns.status")}</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{customersQuery.isLoading && (
{organizations.isError || usage.isError ? (
<ErrorState
message={t("customerPortal.list.error")}
onRetry={() => void (organizations.isError ? organizations.refetch() : usage.refetch())}
/>
) : (
<Table aria-label={t("customerPortal.list.title")}>
<TableHeader>
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
{t("customerPortal.list.loading")}
</TableCell>
<TableHead className="pl-4">{t("customerPortal.list.columns.customer")}</TableHead>
<TableHead className="text-right">{t("customerPortal.list.columns.spend")}</TableHead>
<TableHead className="text-right">{t("customerPortal.list.columns.requests")}</TableHead>
<TableHead className="text-right">{t("customerPortal.list.columns.tokens")}</TableHead>
<TableHead className="pr-4 text-right">{t("customerPortal.list.columns.members")}</TableHead>
</TableRow>
)}
{!customersQuery.isLoading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
{t("customerPortal.list.empty")}
</TableCell>
</TableRow>
)}
{rows.map(({ c, spend, keys, status }) => {
const max = c.litellm_budget_table?.max_budget ?? null;
return (
<TableRow
key={c.user_id}
className="cursor-pointer"
onClick={() => setSelectedId(c.user_id, { history: "push" })}
>
<TableCell className="pl-4">
<p className="font-medium">{c.alias || c.user_id}</p>
<p className="font-mono text-[11px] text-muted-foreground">{c.user_id}</p>
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{t("format.usd", { val: spend })}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{max === null
? t("customerPortal.common.noBudget")
: t("customerPortal.list.budgetUsage", {
spend: t("format.usd", { val: spend }),
max: t("format.usd", { val: max }),
})}
</TableCell>
<TableCell className="text-right tabular-nums">{keys}</TableCell>
<TableCell>
<StatusBadge status={status} />
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
{c.blocked ? (
<Button
size="sm"
variant="outline"
onClick={() => unblock.mutate({ body: { user_id: c.user_id, blocked: false } })}
>
{t("customerPortal.common.unblock")}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => block.mutate({ body: { user_ids: [c.user_id] } })}
>
{t("customerPortal.common.block")}
</Button>
)}
</TableHeader>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
{t("customerPortal.list.loading")}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
{t(search ? "customerPortal.list.noMatches" : "customerPortal.list.empty")}
</TableCell>
</TableRow>
)}
{!loading &&
rows.map((customer) => (
<TableRow key={customer.customer_org_id}>
<TableCell className="pl-4">
<a
className="font-medium text-primary hover:underline"
href={usageHref(uiHref("customers/usage"), {
org: customer.customer_org_id,
from: calendarDate(dateValue.from),
to: calendarDate(dateValue.to),
})}
>
{customer.alias || customer.customer_org_id}
</a>
<p className="font-mono text-[11px] text-muted-foreground">{customer.customer_org_id}</p>
</TableCell>
<TableCell className="text-right tabular-nums">
<CustomerSpend
spend={totals[customer.customer_org_id]?.spend ?? 0}
budgetSpend={customer.spend}
maxBudget={customer.budget?.max_budget ?? null}
/>
</TableCell>
<TableCell className="text-right tabular-nums">
{t("format.number", { val: totals[customer.customer_org_id]?.requests ?? 0 })}
</TableCell>
<TableCell className="text-right tabular-nums">
{t("format.number", { val: totals[customer.customer_org_id]?.tokens ?? 0 })}
</TableCell>
<TableCell className="pr-4 text-right tabular-nums">
{t("format.number", { val: customer.member_count })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<DataTablePagination
page={pagination.pageIndex}
pageSize={pagination.pageSize}
rowCount={organizations.data?.meta.total_count ?? 0}
onPageChange={(pageIndex) => onPaginationChange({ ...pagination, pageIndex })}
onPageSizeChange={(pageSize) => onPaginationChange({ pageSize, pageIndex: 0 })}
isLoading={organizations.isFetching || usage.isFetching}
/>
</div>
<p className="text-xs text-muted-foreground">{t("customerPortal.list.budgetAndMembersHelp")}</p>
<p className="text-xs text-muted-foreground">{t("customerPortal.list.attribution")}</p>
<CreateCustomerDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(id) => {
customersQuery.refetch();
setSelectedId(id, { history: "push" });
}}
onCreated={() => void organizations.refetch()}
/>
</div>
);

View file

@ -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<AdvancedDatePickerProps> = ({
showTimeRange = true,
align = "right",
compact = false,
presets = relativeTimeOptions,
maxRangeDays,
}) => {
const { t } = useTranslation();
const inputId = useId();
@ -57,8 +64,9 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
const dropdownRef = useRef<HTMLDivElement>(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<AdvancedDatePickerProps> = ({
// 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<AdvancedDatePickerProps> = ({
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<AdvancedDatePickerProps> = ({
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<AdvancedDatePickerProps> = ({
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<AdvancedDatePickerProps> = ({
</span>
</div>
<div className={cn("overflow-y-auto", compact ? "grid h-auto grid-cols-2" : "h-[350px]")}>
{relativeTimeOptions.map((option) => {
{presets.map((option) => {
const isSelected = selectedOption === option.shortLabel;
return (
<button
@ -412,11 +429,11 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
<div className={cn("bg-info/10 p-3 rounded-md space-y-1", compact && "col-span-2")}>
<div className="text-xs text-info">
<span className="font-medium">{t("shared.advancedDatePicker.from")}</span>{" "}
{moment(tempValue.from).format("MMM D, YYYY [at] HH:mm:ss")}
{moment(tempValue.from).format(showTimeRange ? "MMM D, YYYY [at] HH:mm:ss" : "MMM D, YYYY")}
</div>
<div className="text-xs text-info">
<span className="font-medium">{t("shared.advancedDatePicker.to")}</span>{" "}
{moment(tempValue.to).format("MMM D, YYYY [at] HH:mm:ss")}
{moment(tempValue.to).format(showTimeRange ? "MMM D, YYYY [at] HH:mm:ss" : "MMM D, YYYY")}
</div>
</div>
)}

View file

@ -8812,6 +8812,7 @@
"selectTimeRange": "Select Time Range",
"invalidDateFormat": "Invalid date format",
"endBeforeStart": "End date cannot be before start date",
"rangeTooLong": "Select a date range of {{days}} days or less.",
"selectDateRange": "Select date range",
"relativeTime": "Relative time",
"startAndEndDates": "Start and end dates",
@ -20600,7 +20601,7 @@
"customerPortal": {
"switcher": {
"label": "Customer Portal",
"description": "End customers and budgets"
"description": "Customer organizations and usage"
},
"status": {
"active": "Active",
@ -20628,19 +20629,30 @@
},
"list": {
"title": "Customers",
"subtitle": "End users of your product, identified by customer id on every request.",
"subtitle": "Usage across each customer organization's teams and members.",
"create": "Create Customer",
"searchPlaceholder": "Search by customer id or alias",
"columns": {
"customer": "Customer",
"spend": "Spend this month",
"spend": "Spend",
"requests": "Requests",
"tokens": "Tokens",
"members": "Members",
"budget": "Budget",
"keys": "Keys",
"status": "Status"
},
"loading": "Loading customers...",
"empty": "No customers yet. Create one to start tracking spend.",
"budgetUsage": "{{spend}} / {{max}}"
"empty": "No customer organizations yet. Create one to get started.",
"noMatches": "No customer organizations match your search.",
"error": "Could not load customer organization usage.",
"attribution": "Usage is attributed to the customer organization and team recorded for each member request. Dates are inclusive UTC calendar days. Tokens include input and output tokens.",
"budgetUsage": "{{spend}} / {{max}}",
"currentBudget": "Current budget {{budget}}",
"budgetUsed": "{{percentage}} used",
"noMaxBudget": "No max budget",
"zeroBudgetPercentage": "Percentage unavailable for a $0 budget",
"budgetAndMembersHelp": "Spend, requests and tokens use the selected dates. Budget percentages use the current budget period, shown under Spend when it differs. Members includes all currently registered members, even those with no usage."
},
"detail": {
"spendThisMonth": "Spend this month",
@ -20667,9 +20679,9 @@
},
"createDialog": {
"title": "Create customer",
"description": "A customer is an end user of your product. Pass its id as `user` on every request to track spend and apply its limits.",
"customerId": "Customer id",
"customerIdPlaceholder": "e.g. acme-corp",
"description": "Create a customer organization. Usage from its registered team members is included in the customer totals.",
"customerId": "Customer organization id",
"customerIdPlaceholder": "e.g. customer-001",
"alias": "Alias",
"aliasPlaceholder": "Optional display name",
"maxBudget": "Monthly budget (USD)",
@ -20687,6 +20699,11 @@
"maxBudgetPlaceholder": "Leave empty for no budget",
"sharedBudgetWarning": "This customer is on budget {{budgetId}}. Changing the budget here changes it for every customer on that budget."
},
"range": {
"label": "Date range (UTC)",
"thisMonth": "This month",
"previousMonth": "Previous month"
},
"analytics": {
"title": "Customer usage",
"customers": "Customers",

View file

@ -9700,6 +9700,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/customer_organizations/daily/activity/aggregated": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Customer Organization Daily Activity Aggregated
* @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.
*/
get: operations["customer_organization_daily_activity_aggregated_management_v1_customer_organizations_daily_activity_aggregated_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/management/v1/customer_organizations/{customer_org_id}": {
parameters: {
query?: never;
@ -42292,6 +42312,12 @@ export interface components {
* @default []
*/
default_budgets: components["schemas"]["OrganizationBudget"][];
/**
* Member Count
* @description Current registered members across all customer teams
* @default 0
*/
member_count: number;
/**
* Spend
* @default 0
@ -68270,6 +68296,8 @@ export interface operations {
query?: {
page?: number;
page_size?: number;
/** @description Case-insensitive substring of the customer ID or alias */
q?: string;
};
header?: never;
path?: never;
@ -68368,6 +68396,44 @@ export interface operations {
};
};
};
customer_organization_daily_activity_aggregated_management_v1_customer_organizations_daily_activity_aggregated_get: {
parameters: {
query: {
/** @description First UTC calendar day, YYYY-MM-DD, inclusive */
start_date: string;
/** @description Last UTC calendar day, YYYY-MM-DD, inclusive, at most 400 days after start */
end_date: string;
/** @description Comma-separated IDs for up to 100 visible customer organizations */
customer_org_ids: string;
model?: string | null;
api_key?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_customer_organization_management_v1_customer_organizations__customer_org_id__get: {
parameters: {
query?: never;

View file

@ -12,11 +12,11 @@ from litellm._logging import verbose_logger
from litellm.caching.caching import DualCache
from litellm.exceptions import SensitiveDataRouteException
from litellm.integrations.custom_guardrail import CustomGuardrail, log_guardrail_information
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import (
ProxyServerRuntime,
VectorStorePreCallHook,
)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.llms.openai import AllMessageValues, ResponsesAPIResponse
@ -216,10 +216,14 @@ async def test_hook_searches_through_the_injected_router_with_the_request_metada
@pytest.mark.asyncio
@pytest.mark.parametrize("sdk_verbose", [False, True])
async def test_hook_falls_back_to_the_sdk_when_the_runtime_has_no_router(
registry_with: RegisterStores,
warnings: list[logging.LogRecord],
monkeypatch: pytest.MonkeyPatch,
sdk_verbose: bool,
) -> None:
monkeypatch.setattr(litellm, "set_verbose", sdk_verbose)
registry_with("vs-sdk", custom_llm_provider="lit6752-not-a-provider")
_, messages, _ = await _run_hook(
@ -229,13 +233,16 @@ async def test_hook_falls_back_to_the_sdk_when_the_runtime_has_no_router(
)
assert messages == [{"role": "user", "content": "what is litellm?"}]
assert len(warnings) == 1
assert (
warnings[0]
.getMessage()
.startswith("Vector store search failed for vector_store_id=vs-sdk, continuing without its context: ")
search_warnings: Final = tuple(
record.getMessage()
for record in warnings
if record.getMessage().startswith("Vector store search failed for vector_store_id=")
)
assert "is not a valid LlmProviders" in warnings[0].getMessage()
assert len(search_warnings) == 1
assert search_warnings[0].startswith(
"Vector store search failed for vector_store_id=vs-sdk, continuing without its context: "
)
assert "is not a valid LlmProviders" in search_warnings[0]
@pytest.mark.asyncio

View file

@ -1,5 +1,5 @@
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from functools import reduce
from typing import Final, cast
@ -14,6 +14,7 @@ from litellm.proxy.list_api.common import ManagementProblem, problem_response
from litellm.proxy.management_endpoints.management_v1.customer_usage import get_customer_usage_prisma, router
from litellm.proxy.utils import PrismaClient
from litellm.types.proxy.management_endpoints.common_daily_activity import SpendMetrics
from tests.unit.proxy.management_endpoints.test_common_daily_activity import _grouping_row
class Record(BaseModel):
@ -154,7 +155,11 @@ def usage_row(
)
def client(rows: tuple[DailyRecord, ...] = (), role: LitellmUserRoles = LitellmUserRoles.PROXY_ADMIN) -> TestClient:
def client(
rows: tuple[DailyRecord, ...] = (),
role: LitellmUserRoles = LitellmUserRoles.PROXY_ADMIN,
database: Database | None = None,
) -> TestClient:
app: Final = FastAPI()
app.include_router(router)
@ -163,7 +168,7 @@ def client(rows: tuple[DailyRecord, ...] = (), role: LitellmUserRoles = LitellmU
return problem_response(exc.problem)
auth: Final = UserAPIKeyAuth(user_id="admin", user_role=role)
prisma: Final = cast(PrismaClient, Client(Database(Table(rows)))) # cast-ok: in-memory Prisma boundary
prisma: Final = cast(PrismaClient, Client(database or Database(Table(rows)))) # cast-ok: in-memory Prisma boundary
app.dependency_overrides[user_api_key_auth] = lambda: auth
app.dependency_overrides[get_customer_usage_prisma] = lambda: prisma
return TestClient(app)
@ -276,3 +281,79 @@ def test_unmatched_entities_return_the_standard_empty_response() -> None:
assert response.status_code == 200, response.text
assert response.json()["results"] == []
assert response.json()["metadata"]["total_spend"] == 0
RANGE: Final = {"start_date": "2024-01-01", "end_date": "2024-01-31"}
class ActivityDatabase(Database):
async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]:
if "GROUPING SETS" in query:
values: Final = {
**asdict(_grouping_row(63, spend=500)),
"prompt_tokens": 3000,
"completion_tokens": 2000,
"api_requests": 1000,
}
return ({**values, "group_level": 127}, values)
if "api_key_rolled" in query:
return (
{
**asdict(_grouping_row(63, spend=500)),
"entity_id": "org-a",
"api_key_rolled": 1,
"prompt_tokens": 3000,
"completion_tokens": 2000,
"api_requests": 1000,
},
)
raise AssertionError(f"Unexpected query: {query}")
def test_aggregated_activity_returns_complete_database_totals_as_one_page() -> None:
response: Final = client(database=ActivityDatabase(Table())).get(
"/management/v1/customer_organizations/daily/activity/aggregated", params={**RANGE, "customer_org_ids": "org-a"}
)
assert response.status_code == 200, response.text
body: Final = response.json()
assert body["metadata"]["total_spend"] == 500
assert body["metadata"]["total_api_requests"] == 1000
assert body["metadata"]["total_tokens"] == 5000
assert body["metadata"]["has_more"] is False
assert body["metadata"]["total_pages"] == 1
assert body["results"][0]["breakdown"]["entities"]["org-a"]["metrics"]["spend"] == 500
@pytest.mark.parametrize("role", (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY))
def test_non_admins_cannot_read_customer_usage(role: LitellmUserRoles) -> None:
response: Final = client(role=role).get(
"/management/v1/customer_organizations/daily/activity/aggregated", params=RANGE
)
assert response.status_code == 403
@pytest.mark.parametrize(
"params,status",
(
({**RANGE, "customer_org_ids": ""}, 422),
({**RANGE, "customer_org_ids": ",".join(str(index) for index in range(101))}, 422),
({"start_date": "2020-01-01", "end_date": "2024-01-01", "customer_org_ids": "org-a"}, 400),
({**RANGE, "start_date": "2024-02-01", "customer_org_ids": "org-a"}, 400),
({**RANGE, "start_date": "2024-02-30", "customer_org_ids": "org-a"}, 400),
({"start_date": "2024-01-01", "customer_org_ids": "org-a"}, 422),
({**RANGE, "customer_org_ids": "org-a", "unknown": "true"}, 400),
),
)
def test_aggregate_work_is_bounded(params: Mapping[str, str], status: int) -> None:
response: Final = client(database=ActivityDatabase(Table())).get(
"/management/v1/customer_organizations/daily/activity/aggregated", params=params
)
assert response.status_code == status, response.text
def test_duplicate_filters_are_rejected() -> None:
response: Final = client(database=ActivityDatabase(Table())).get(
"/management/v1/customer_organizations/daily/activity/aggregated"
"?start_date=2024-01-01&start_date=2024-01-02&end_date=2024-01-31&customer_org_ids=org-a"
)
assert response.status_code == 400, response.text

View file

@ -503,7 +503,7 @@ def test_partial_member_override_updates_preserve_other_fields_and_never_change_
assert inherited.json()["effective_budget"]["allowed_models"] == ["model-a"]
unchanged: Final = http.get("/management/v1/customer_organizations/org")
assert unchanged.status_code == 200, unchanged.text
assert unchanged.json() == organization
assert unchanged.json() == {**organization, "member_count": 1}
def test_first_added_template_is_default_and_default_cannot_be_unset_without_replacement() -> None:
@ -539,3 +539,35 @@ def test_template_routes_return_404_for_a_missing_organization(
) -> None:
response: Final = client().request(method, "/management/v1" + path, json=body)
assert response.status_code == 404, response.text
def test_organization_list_counts_all_members_and_searches_before_paginating() -> None:
http: Final = client()
family(http)
for identifier in ("member-a", "member-b"):
created: Final = http.post(
"/management/v1/customer_members", json={"customer_member_id": identifier, "parent_team_id": "team"}
)
assert created.status_code == 201, created.text
second_team: Final = http.post(
"/management/v1/customer_teams", json={"customer_team_id": "second-team", "parent_org_id": "org"}
)
assert second_team.status_code == 201, second_team.text
third_member: Final = http.post(
"/management/v1/customer_members", json={"customer_member_id": "member-c", "parent_team_id": "second-team"}
)
assert third_member.status_code == 201, third_member.text
other: Final = http.post(
"/management/v1/customer_organizations", json={"customer_org_id": "other", "alias": "Literal%_"}
)
assert other.status_code == 201, other.text
page: Final = http.get("/management/v1/customer_organizations", params={"q": "ORG", "page_size": 1})
assert page.status_code == 200, page.text
assert page.json()["meta"]["total_count"] == 1
assert page.json()["data"][0]["member_count"] == 3
assert page.json()["data"][0]["spend"] == 0
assert page.json()["data"][0]["budget"]["max_budget"] == 1000
literal: Final = http.get("/management/v1/customer_organizations", params={"q": "%_"})
assert literal.json()["data"][0]["customer_org_id"] == "other"
assert literal.json()["data"][0]["member_count"] == 0
assert literal.json()["meta"]["total_count"] == 1

View file

@ -1,3 +1,5 @@
import json
import sqlite3
from collections.abc import AsyncIterator, Mapping, Sequence
from contextlib import asynccontextmanager
from datetime import datetime, timezone
@ -18,6 +20,29 @@ class Record(BaseModel):
model_config = ConfigDict(frozen=True, extra="allow")
def matches_where(row: Mapping[str, object], where: Mapping[str, object]) -> bool:
def condition(key: str, value: object) -> bool:
if key in ("OR", "AND"):
nested: Final = TypeAdapter(tuple[Mapping[str, object], ...]).validate_python(value)
matched: Final = tuple(matches_where(row, part) for part in nested)
return any(matched) if key == "OR" else all(matched)
if isinstance(value, Mapping):
if "in" in value:
return row.get(key) in value["in"]
if "not" in value:
return not matches_where(row, {key: value["not"]})
if "contains" in value:
text: Final = str(value["contains"]).replace("\\%", "%").replace("\\_", "_").replace("\\\\", "\\")
return text.lower() in str(row.get(key) or "").lower()
return all(
str(row.get(key)) >= str(bound) if op == "gte" else str(row.get(key)) <= str(bound)
for op, bound in value.items()
)
return row.get(key) == value
return all(condition(key, value) for key, value in where.items())
class Table:
def __init__(self, id_field: str, database: "Database") -> None:
self.id_field = id_field
@ -77,18 +102,15 @@ class Table:
skip: int = 0,
take: int | None = None,
) -> tuple[Record, ...]:
matches: Final = tuple(
row
for row in self.rows.values()
if all(
row.model_dump().get(key) in value["in"]
if isinstance(value, Mapping)
else row.model_dump().get(key) == value
for key, value in (where or {}).items()
)
)
matches: Final = tuple(row for row in self.rows.values() if matches_where(row.model_dump(), where or {}))
ordered: Final = reduce(
lambda rows, key: tuple(sorted(rows, key=lambda row: str(row.model_dump().get(next(iter(key)))))),
lambda rows, key: tuple(
sorted(
rows,
key=lambda row: str(row.model_dump().get(next(iter(key)))),
reverse=next(iter(key.values())) == "desc",
)
),
reversed(order or ()),
matches,
)
@ -118,10 +140,37 @@ class Database:
self.litellm_budgettable = Table("budget_id", self)
self.locked_organization_ids: tuple[str, ...] = ()
async def query_raw(self, query: str, *args: object) -> tuple[()]:
assert "FOR UPDATE" in query
self.locked_organization_ids = (*self.locked_organization_ids, *(str(arg) for arg in args))
return ()
async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]:
if "FOR UPDATE" in query:
self.locked_organization_ids = (*self.locked_organization_ids, *(str(arg) for arg in args))
return ()
with sqlite3.connect(":memory:") as connection:
connection.row_factory = sqlite3.Row
connection.executescript(
'CREATE TABLE "LiteLLM_CustomerTeamTable" (customer_team_id TEXT, parent_org_id TEXT);'
'CREATE TABLE "LiteLLM_CustomerMemberTable" (customer_member_id TEXT, parent_team_id TEXT);'
)
connection.executemany(
'INSERT INTO "LiteLLM_CustomerTeamTable" VALUES (?, ?)',
tuple(
(row.model_dump()["customer_team_id"], row.model_dump()["parent_org_id"])
for row in self.litellm_customerteamtable.rows.values()
),
)
connection.executemany(
'INSERT INTO "LiteLLM_CustomerMemberTable" VALUES (?, ?)',
tuple(
(row.model_dump()["customer_member_id"], row.model_dump()["parent_team_id"])
for row in self.litellm_customermembertable.rows.values()
),
)
return tuple(
dict(row)
for row in connection.execute(
query.replace("= ANY($1::text[])", "IN (SELECT value FROM json_each($1))"),
tuple(json.dumps(arg) for arg in args),
)
)
@asynccontextmanager
async def tx(self) -> AsyncIterator["Database"]: