mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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:
parent
07fe19bb15
commit
9633be87ea
18 changed files with 1011 additions and 216 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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" };
|
||||
};
|
||||
|
|
@ -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;
|
||||
}, {});
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
66
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
66
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue