From a60e16b87dec63dedee586af629db77dd8a597fa Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Sat, 14 Nov 2026 01:39:50 +0800 Subject: [PATCH] fix(proxy): display customer identity in spend logs (#51596) * fix(proxy): display customer identity in spend logs Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> * fix(proxy): omit absent customer identity from spend metadata Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> * fix(proxy): retain only authorized customer log identities Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> * feat(ui): clarify customer identities on spend logs Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- litellm/proxy/_types.py | 6 + litellm/proxy/auth/customer_hierarchy_auth.py | 49 ++++++-- litellm/proxy/auth/user_api_key_auth.py | 32 +++-- litellm/proxy/litellm_pre_call_utils.py | 3 + .../spend_tracking/spend_tracking_utils.py | 32 +++++ .../types/proxy/auth/customer_hierarchy.py | 4 + litellm/types/utils.py | 3 + .../components/logs/customerIdentity.test.ts | 43 +++++++ .../src/components/logs/customerIdentity.ts | 27 ++++ .../logs/detail/CustomerSection.test.tsx | 91 ++++++++++++++ .../logs/detail/CustomerSection.tsx | 93 ++++++++++++++ .../logs/detail/LogDetailContent.tsx | 3 + .../request/RequestLogsTableColumns.test.tsx | 24 ++++ .../logs/request/RequestLogsTableColumns.tsx | 21 ++++ .../src/i18n/locales/en.json | 10 ++ .../src/lib/http/schema.d.ts | 8 ++ .../auth/test_customer_hierarchy_auth.py | 18 +++ .../unit/proxy/auth/test_user_api_key_auth.py | 118 ++++++++++++++++++ .../test_spend_tracking_utils.py | 79 ++++++++++++ 19 files changed, 644 insertions(+), 20 deletions(-) create mode 100644 litellm/ui/litellm-dashboard/src/components/logs/customerIdentity.test.ts create mode 100644 litellm/ui/litellm-dashboard/src/components/logs/customerIdentity.ts create mode 100644 litellm/ui/litellm-dashboard/src/components/logs/detail/CustomerSection.test.tsx create mode 100644 litellm/ui/litellm-dashboard/src/components/logs/detail/CustomerSection.tsx diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index bc5c63cb7be..ed4660d5635 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3870,6 +3870,12 @@ class SpendLogsRouterMetadata(TypedDict): class SpendLogsMetadata(TypedDict): + user_api_key_customer_org_id: ReadOnly[NotRequired[str | None]] + user_api_key_customer_org_alias: ReadOnly[NotRequired[str | None]] + user_api_key_customer_team_id: ReadOnly[NotRequired[str | None]] + user_api_key_customer_team_alias: ReadOnly[NotRequired[str | None]] + user_api_key_customer_member_id: ReadOnly[NotRequired[str | None]] + user_api_key_customer_member_alias: ReadOnly[NotRequired[str | None]] user_api_key_customer_usage: ReadOnly[NotRequired[str | None]] actor_agent_id: ReadOnly[NotRequired[str | None]] target_agent_id: ReadOnly[NotRequired[str | None]] diff --git a/litellm/proxy/auth/customer_hierarchy_auth.py b/litellm/proxy/auth/customer_hierarchy_auth.py index 530410117c7..c3129ac8025 100644 --- a/litellm/proxy/auth/customer_hierarchy_auth.py +++ b/litellm/proxy/auth/customer_hierarchy_auth.py @@ -71,6 +71,7 @@ class _OrganizationRow(BaseModel): model_config = ConfigDict(frozen=True, extra="ignore") customer_org_id: str + alias: str | None = None spend: float = 0.0 budget: Mapping[str, object] | None = None @@ -79,6 +80,7 @@ class _TeamRow(BaseModel): model_config = ConfigDict(frozen=True, extra="ignore") customer_team_id: str + alias: str | None = None spend: float = 0.0 budget: Mapping[str, object] | None = None budget_override: Mapping[str, object] | None = None @@ -89,6 +91,7 @@ class _MemberRow(BaseModel): model_config = ConfigDict(frozen=True, extra="ignore") customer_member_id: str + alias: str | None = None spend: float = 0.0 budget: Mapping[str, object] | None = None budget_override: Mapping[str, object] | None = None @@ -105,6 +108,19 @@ class AmbiguousOrganizationAlias: alias: str +class CustomerBudgetExceededError(litellm.BudgetExceededError): + def __init__(self, hierarchy: CustomerHierarchyAuth, error: litellm.BudgetExceededError) -> None: + super().__init__( + current_cost=error.current_cost, + max_budget=error.max_budget, + message=error.message, + llm_provider=error.llm_provider, + entity_type=error.entity_type, + entity_id=error.entity_id, + ) + self.customer_hierarchy: Final = hierarchy + + def customer_member_cache_key(customer_member_id: str) -> str: return f"customer_member_id:{customer_member_id}" @@ -178,7 +194,7 @@ def _team_scope(team: _TeamRow, now: datetime) -> CustomerScopeAuth: team.spend, effective_member_budget(budget, override, team.budget_override_expires_at, now), customer_row_reset_at(team, now), - ) + ).model_copy(update={"alias": team.alias}) def customer_hierarchy_from_member_row(row: Mapping[str, object], now: datetime) -> CustomerHierarchyAuth: @@ -195,8 +211,11 @@ def customer_hierarchy_from_member_row(row: Mapping[str, object], now: datetime) ) return CustomerHierarchyAuth( customer_member_id=member.customer_member_id, + customer_member_alias=member.alias, customer_team_id=team_scopes[0].entity_id if team_scopes else None, + customer_team_alias=team_scopes[0].alias if team_scopes else None, customer_org_id=organization.customer_org_id, + customer_org_alias=organization.alias, scopes=( _scope( "customer_org", @@ -226,6 +245,7 @@ def select_customer_team(hierarchy: CustomerHierarchyAuth, customer_team_id: str return hierarchy.model_copy( update={ "customer_team_id": customer_team_id, + "customer_team_alias": selected.alias, "scopes": tuple(selected if scope.key == "customer_team" else scope for scope in hierarchy.scopes), } ) @@ -236,6 +256,7 @@ def customer_hierarchy_from_organization_row(row: Mapping[str, object], now: dat organization: Final = _OrganizationRow.model_validate(row) return CustomerHierarchyAuth( customer_org_id=organization.customer_org_id, + customer_org_alias=organization.alias, scopes=( _scope( "customer_org", @@ -692,16 +713,30 @@ async def resolve_customer_hierarchy( if cached is None: return None hierarchy: Final = await current_customer_spend(cached, get_current_spend) - check_customer_hierarchy_key_scope(hierarchy, metadata) - if not RouteChecks.is_info_route(route): - check_customer_hierarchy_budget(hierarchy) - if any(scope.budget_limits for scope in hierarchy.scopes): - await check_customer_hierarchy_windows(hierarchy) + try: + await check_customer_hierarchy_access(hierarchy, llm_router, request_model, route, key_metadata) + except litellm.BudgetExceededError as error: + raise CustomerBudgetExceededError(hierarchy, error) from error + return hierarchy + + +async def check_customer_hierarchy_access( + hierarchy: CustomerHierarchyAuth, + llm_router: "RegistryReader | None", + request_model: str | Sequence[str] | None, + route: str, + key_metadata: Mapping[str, object] | None = None, +) -> None: + check_customer_hierarchy_key_scope(hierarchy, key_metadata or {}) requested: Final = (request_model,) if isinstance(request_model, str) else tuple(request_model or ()) for model in requested: if model: check_customer_hierarchy_model_access(hierarchy, model, llm_router) - return hierarchy + if RouteChecks.is_info_route(route): + return + check_customer_hierarchy_budget(hierarchy) + if any(scope.budget_limits for scope in hierarchy.scopes): + await check_customer_hierarchy_windows(hierarchy) def check_customer_hierarchy_key_scope(hierarchy: CustomerHierarchyAuth, key_metadata: Mapping[str, object]) -> None: diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index d1f78c24477..7cbfb20aea5 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -131,6 +131,7 @@ from litellm.proxy.auth.auth_utils import ( route_in_additonal_public_routes, ) from litellm.proxy.auth.customer_hierarchy_auth import ( + CustomerBudgetExceededError, customer_identity_from_headers, resolve_customer_hierarchy, ) @@ -4555,19 +4556,24 @@ async def _authorize_authenticated_request( user_api_key_auth_obj.customer_hierarchy = None # rebind-ok: request state, never inherited from a cached key if customer_identity is not None and not _is_unauthenticated_public_route(route): with tracer.trace("litellm.proxy.auth.resolve_customer_hierarchy"): # pyright: ignore[reportUnknownMemberType] # tracer is the untyped proxy tracer - user_api_key_auth_obj.customer_hierarchy = await resolve_customer_hierarchy( # rebind-ok: the gate updates the request-owned auth object in place - identity=customer_identity, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - llm_router=llm_router, - request_model=_get_model_from_request_context( - request_data, route, request, llm_router, team_id=user_api_key_auth_obj.team_id - ), - route=route, - key_metadata=_KEY_METADATA_ADAPTER.validate_python( - user_api_key_auth_obj.metadata or {} # pyright: ignore[reportUnknownMemberType] # untyped key metadata, validated here - ), - ) + try: + hierarchy: Final = await resolve_customer_hierarchy( + identity=customer_identity, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + llm_router=llm_router, + request_model=_get_model_from_request_context( + request_data, route, request, llm_router, team_id=user_api_key_auth_obj.team_id + ), + route=route, + key_metadata=_KEY_METADATA_ADAPTER.validate_python( + user_api_key_auth_obj.metadata or {} # pyright: ignore[reportUnknownMemberType] # untyped key metadata, validated here + ), + ) + except CustomerBudgetExceededError as error: + user_api_key_auth_obj.customer_hierarchy = error.customer_hierarchy # rebind-ok: request state + raise + user_api_key_auth_obj.customer_hierarchy = hierarchy # rebind-ok: request state grants: Final = await access_group_ids_for_identity(user_api_key_auth_obj) user_api_key_auth_obj.apply_access_group_rule_grants(grants) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index c8e37f8cc7f..98d6941a165 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -2141,6 +2141,9 @@ class LiteLLMProxyRequestSetup: user_api_key_customer_member_id=customer.customer_member_id if customer else None, user_api_key_customer_team_id=customer.customer_team_id if customer else None, user_api_key_customer_org_id=customer.customer_org_id if customer else None, + user_api_key_customer_org_alias=customer.customer_org_alias if customer else None, + user_api_key_customer_team_alias=customer.customer_team_alias if customer else None, + user_api_key_customer_member_alias=customer.customer_member_alias if customer else None, user_api_key_customer_budget_scopes=customer_budget_scopes_metadata(customer), user_api_key_customer_hierarchy=customer.model_dump(mode="json") if customer else None, user_api_key_user_email=user_api_key_dict.user_email, diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 8cbc7d5fb16..31fc669568a 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -216,6 +216,12 @@ _STAMPED_METADATA_KEYS: Final = frozenset( "is_silent_experiment", BILLED_RETRY_ATTEMPT_METADATA_KEY, "user_api_key_user_email", + "user_api_key_customer_org_id", + "user_api_key_customer_org_alias", + "user_api_key_customer_team_id", + "user_api_key_customer_team_alias", + "user_api_key_customer_member_id", + "user_api_key_customer_member_alias", ) ) @@ -229,6 +235,30 @@ def _stored_user_email(metadata: Mapping[str, object]) -> str | None: return email if isinstance(email, str) and email.strip() else None +_CUSTOMER_METADATA_ADAPTER: Final[TypeAdapter[Mapping[object, object]]] = TypeAdapter(Mapping[object, object]) + + +class _CustomerIdentityMetadata(TypedDict, total=False): + user_api_key_customer_org_id: ReadOnly[str] + user_api_key_customer_org_alias: ReadOnly[str] + user_api_key_customer_team_id: ReadOnly[str] + user_api_key_customer_team_alias: ReadOnly[str] + user_api_key_customer_member_id: ReadOnly[str] + user_api_key_customer_member_alias: ReadOnly[str] + + +_CUSTOMER_IDENTITY_ADAPTER: Final[TypeAdapter[_CustomerIdentityMetadata]] = TypeAdapter(_CustomerIdentityMetadata) + + +def _stored_customer_identity(metadata: Mapping[object, object]) -> _CustomerIdentityMetadata: + values: Final = { + key: value + for key in _CustomerIdentityMetadata.__annotations__ + if isinstance(value := metadata.get(key), str) and value.strip() + } + return _CUSTOMER_IDENTITY_ADAPTER.validate_python(values) + + def _get_spend_logs_metadata( metadata: dict | None, applied_guardrails: list[str] | None = None, @@ -312,10 +342,12 @@ def _get_spend_logs_metadata( retry_key: Final = BILLED_RETRY_ATTEMPT_METADATA_KEY billed_retry_attempt: Final = retry_key in metadata and metadata[retry_key] is True # Filter the metadata dictionary to include only the specified keys + customer_metadata: Final = _CUSTOMER_METADATA_ADAPTER.validate_python(metadata) clean_metadata: Final = SpendLogsMetadata( **MappingProxyType( {key: metadata.get(key) for key in SpendLogsMetadata.__annotations__ if key not in _STAMPED_METADATA_KEYS} ), + **_stored_customer_identity(customer_metadata), **upstream_provider_metadata, autorouter_savings=autorouter_savings, autorouter_savings_estimate=autorouter_savings_estimate, diff --git a/litellm/types/proxy/auth/customer_hierarchy.py b/litellm/types/proxy/auth/customer_hierarchy.py index 0c47d7f963e..630d5d2a64b 100644 --- a/litellm/types/proxy/auth/customer_hierarchy.py +++ b/litellm/types/proxy/auth/customer_hierarchy.py @@ -29,6 +29,7 @@ class CustomerScopeAuth(BaseModel): key: CustomerScopeKey entity_id: str + alias: str | None = None spend: float = 0.0 budget_reset_at: datetime | None = None max_budget: float | None = None @@ -47,8 +48,11 @@ class CustomerHierarchyAuth(BaseModel): model_config = ConfigDict(frozen=True) customer_org_id: str + customer_org_alias: str | None = None customer_team_id: str | None = None + customer_team_alias: str | None = None customer_member_id: str | None = None + customer_member_alias: str | None = None scopes: tuple[CustomerScopeAuth, ...] team_scopes: tuple[CustomerScopeAuth, ...] = Field( default=(), diff --git a/litellm/types/utils.py b/litellm/types/utils.py index f391d9c77b0..958a74f12dd 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3148,6 +3148,9 @@ class StandardLoggingUserAPIKeyMetadata(TypedDict): user_api_key_customer_member_id: ReadOnly[str | None] user_api_key_customer_team_id: ReadOnly[str | None] user_api_key_customer_org_id: ReadOnly[str | None] + user_api_key_customer_org_alias: ReadOnly[NotRequired[str | None]] + user_api_key_customer_team_alias: ReadOnly[NotRequired[str | None]] + user_api_key_customer_member_alias: ReadOnly[NotRequired[str | None]] user_api_key_customer_budget_scopes: ReadOnly[NotRequired[str | None]] user_api_key_customer_hierarchy: NotRequired[ReadOnly[Mapping[str, object] | None]] user_api_key_request_route: str | None diff --git a/litellm/ui/litellm-dashboard/src/components/logs/customerIdentity.test.ts b/litellm/ui/litellm-dashboard/src/components/logs/customerIdentity.test.ts new file mode 100644 index 00000000000..31a3edf737e --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/components/logs/customerIdentity.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { customerLogIdentity } from "./customerIdentity"; + +describe("customerLogIdentity", () => { + it("prefers persisted names and summarizes a recognized customer org and customer member", () => { + const metadata = { + user_api_key_customer_org_id: "org-id", + user_api_key_customer_org_alias: "Org display", + user_api_key_customer_team_id: "team-id", + user_api_key_customer_team_alias: "Team display", + user_api_key_customer_member_id: "member-id", + user_api_key_customer_member_alias: "Member display", + }; + const identity = customerLogIdentity(metadata, "legacy-customer"); + expect(identity.recognized).toBe(true); + expect(identity.rows.map((row) => row.name)).toEqual(["Org display", "Team display", "Member display"]); + expect(identity.summary.map((row) => row.name)).toEqual(["Org display", "Member display"]); + }); + + it("treats a legacy end user without a hierarchy as asserted", () => { + expect(customerLogIdentity({}, "legacy-customer")).toEqual({ + rows: [{ scope: "legacy", name: "legacy-customer" }], + summary: [{ scope: "legacy", name: "legacy-customer" }], + recognized: false, + }); + }); + + it("does not recognize an alias or lone customer member ID without a resolved customer org", () => { + const identity = customerLogIdentity({ + user_api_key_customer_org_alias: "asserted-org", + user_api_key_customer_member_id: "member-id", + }); + expect(identity.recognized).toBe(false); + expect(identity.summary.map((row) => row.name)).toEqual(["asserted-org", "member-id"]); + }); + + it.each([undefined, null, {}, { user_api_key_customer_org_id: 42, user_api_key_customer_member_id: " " }])( + "ignores missing or malformed identities (%j)", + (metadata) => { + expect(customerLogIdentity(metadata, " ").rows).toEqual([]); + }, + ); +}); diff --git a/litellm/ui/litellm-dashboard/src/components/logs/customerIdentity.ts b/litellm/ui/litellm-dashboard/src/components/logs/customerIdentity.ts new file mode 100644 index 00000000000..17adea3fff5 --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/components/logs/customerIdentity.ts @@ -0,0 +1,27 @@ +export interface CustomerLogRow { + scope: "org" | "team" | "member" | "legacy"; + id?: string; + name: string; +} + +function identityString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +export function customerLogIdentity(metadata?: Record | null, endUser?: string) { + const rows: CustomerLogRow[] = (["org", "team", "member"] as const).flatMap((scope) => { + const id = identityString(metadata?.[`user_api_key_customer_${scope}_id`]); + const alias = identityString(metadata?.[`user_api_key_customer_${scope}_alias`]); + const name = alias ?? id; + return name ? [{ scope, id, name }] : []; + }); + const legacy = identityString(endUser); + const fallbackRows: CustomerLogRow[] = legacy ? [{ scope: "legacy", name: legacy }] : []; + const visibleRows = rows.length ? rows : fallbackRows; + const summary = visibleRows.filter((row) => row.scope === "org" || row.scope === "member"); + return { + rows: visibleRows, + summary: summary.length ? summary : visibleRows.slice(0, 1), + recognized: Boolean(identityString(metadata?.user_api_key_customer_org_id)), + }; +} diff --git a/litellm/ui/litellm-dashboard/src/components/logs/detail/CustomerSection.test.tsx b/litellm/ui/litellm-dashboard/src/components/logs/detail/CustomerSection.test.tsx new file mode 100644 index 00000000000..24dc142e82e --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/components/logs/detail/CustomerSection.test.tsx @@ -0,0 +1,91 @@ +import type { PropsWithChildren } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CustomerSection } from "./CustomerSection"; + +vi.mock("@/components/ui/card", () => ({ + Card: ({ children }: PropsWithChildren) =>
{children}
, + CardHeader: ({ children }: PropsWithChildren) =>
{children}
, + CardTitle: ({ children }: PropsWithChildren) =>
{children}
, + CardContent: ({ children }: PropsWithChildren) =>
{children}
, +})); + +vi.mock("./TruncatedValue", () => ({ + TruncatedValue: ({ value }: { value: string }) => , +})); + +describe("CustomerSection", () => { + it("summarizes names when collapsed and links each recognized customer scope when expanded", () => { + render( + , + ); + + const trigger = screen.getByRole("button", { name: /Customer org.*Customer org display/ }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(trigger).toHaveTextContent("Customer member display"); + expect(trigger).toHaveTextContent("Recognized"); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + fireEvent.click(trigger); + for (const scope of ["org", "team", "member"]) { + expect(screen.getByRole("button", { name: `Copy customer-${scope}-1` })).toBeInTheDocument(); + const url = new URL( + screen.getByRole("link", { name: `View Customer ${scope}` }).getAttribute("href")!, + "http://localhost", + ); + expect(url.pathname).toMatch(/customers\/manage$/); + expect(url.searchParams.get("org")).toBe("customer-org-1"); + expect(url.searchParams.get("team")).toBe(scope === "org" ? null : "customer-team-1"); + expect(url.searchParams.get("member")).toBe(scope === "member" ? "customer-member-1" : null); + } + }); + + it("shows a customer org without inventing customer team or customer member rows", () => { + render(); + + expect(screen.getByText("Customer org")).toBeInTheDocument(); + expect(screen.queryByText("Customer team")).not.toBeInTheDocument(); + expect(screen.queryByText("Customer member")).not.toBeInTheDocument(); + }); + + it("displays historical customer IDs without aliases", () => { + render(); + + expect(screen.getByText("Asserted")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Customer member.*historical-customer-member/ })); + expect(screen.getByRole("button", { name: "Copy historical-customer-member" })).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("shows a legacy end user as an asserted Customer without hierarchy links", () => { + render(); + expect(screen.getByText("Asserted")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /legacy-customer/ })).toHaveTextContent("legacy-customer"); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it.each([ + undefined, + null, + {}, + { user_api_key_team_id: "internal-team" }, + { + user_api_key_customer_org_id: 42, + user_api_key_customer_team_id: { invalid: true }, + user_api_key_customer_member_id: " ", + user_api_key_customer_org_alias: [], + }, + ])("hides the section without valid customer identity (%j)", (metadata) => { + render(); + + expect(screen.queryByText("Customer")).not.toBeInTheDocument(); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/components/logs/detail/CustomerSection.tsx b/litellm/ui/litellm-dashboard/src/components/logs/detail/CustomerSection.tsx new file mode 100644 index 00000000000..4ceeca5b018 --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/components/logs/detail/CustomerSection.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from "react-i18next"; +import { ExternalLink } from "lucide-react"; +import { customerHierarchyHref } from "@/app/(dashboard)/customers/_components/CustomerHierarchyNavigation"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { customerLogIdentity, type CustomerLogRow } from "../customerIdentity"; +import { TruncatedValue } from "./TruncatedValue"; + +interface CustomerSectionProps { + metadata?: Record | null; + endUser?: string; +} + +export function CustomerSection({ metadata, endUser }: CustomerSectionProps) { + const { t } = useTranslation(); + const { rows, summary, recognized } = customerLogIdentity(metadata, endUser); + if (rows.length === 0) return null; + + const label = (row: CustomerLogRow) => t(`logs.detail.customer.${row.scope === "legacy" ? "title" : row.scope}`); + const org = rows.find((row) => row.scope === "org")?.id; + const team = rows.find((row) => row.scope === "team")?.id ?? null; + const href = (row: CustomerLogRow) => { + if (!org || !row.id || row.scope === "legacy") return undefined; + return customerHierarchyHref("manage", { + org, + team: row.scope === "org" ? null : team, + member: row.scope === "member" ? row.id : null, + }); + }; + + return ( + + + + + + {t("logs.detail.customer.title")} + + {summary.map((row) => ( + + + {label(row)} + + + {row.name} + + + ))} + + + {t(`logs.detail.customer.${recognized ? "recognized" : "asserted"}`)} + + + +
+ {rows.map((row) => ( +
+
{label(row)}
+
+ {row.name !== row.id &&
{row.name}
} + {row.id && ( +
+ +
+ )} +
+
+ {href(row) && ( + + )} +
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/litellm/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx b/litellm/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx index 42d97710cef..5216e9046a2 100644 --- a/litellm/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx +++ b/litellm/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx @@ -28,6 +28,7 @@ import { ConfigInfoMessage } from "./sections/ConfigInfoMessage"; import { VectorStoreViewer } from "./sections/VectorStoreViewer"; import { CREDENTIAL_LABELS, KUBERNETES_POD_ROUTING_LABELS } from "../constants"; import { TruncatedValue } from "./TruncatedValue"; +import { CustomerSection } from "./CustomerSection"; import { TokenFlow } from "./TokenFlow"; import { JsonViewer } from "./JsonViewer"; import { RoutingDecisionCard, type RoutingDecision } from "./RoutingDecisionCard"; @@ -206,6 +207,8 @@ export function LogDetailContent({ {/* Metrics */} + + {/* Cost Breakdown */} ): LogEntry => ({ const noopDeps = { onKeyHashClick: vi.fn(), onSessionClick: vi.fn() }; +describe("Customer column", () => { + it("shows recognized customer names instead of IDs", () => { + renderRows([ + logEntry({ + metadata: { + user_api_key_customer_org_id: "org-id", + user_api_key_customer_org_alias: "Org display", + user_api_key_customer_member_id: "member-id", + user_api_key_customer_member_alias: "Member display", + }, + }), + ]); + const cell = screen.getByRole("cell", { name: /Member display/ }); + expect(cell).toHaveTextContent("Org display"); + expect(cell).toHaveTextContent("Recognized"); + expect(cell).not.toHaveTextContent("member-id"); + }); + + it("shows a legacy customer as asserted", () => { + renderRows([logEntry({ end_user: "legacy-customer" })]); + expect(screen.getByRole("cell", { name: /legacy-customer.*Asserted/ })).toHaveTextContent("legacy-customer"); + }); +}); + function renderRows(rows: LogEntry[], deps = noopDeps) { render( , }, + { + id: "customer", + header: t("logs.detail.customer.title"), + size: 190, + enableSorting: false, + cell: ({ row }) => { + const { summary, recognized } = customerLogIdentity(row.original.metadata, row.original.end_user); + if (!summary.length) return -; + return ( +
+ {summary.map((customer) => ( + + ))} + + {t(`logs.detail.customer.${recognized ? "recognized" : "asserted"}`)} + +
+ ); + }, + }, { id: "key_hash", header: t("logs.request.columns.keyHash"), diff --git a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json index eaa4e7efd5f..9b97df33bcd 100644 --- a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json @@ -14402,6 +14402,16 @@ "filePlaceholder": "[File]", "audioPlaceholder": "[Audio]" }, + "customer": { + "title": "Customer", + "org": "Customer org", + "team": "Customer team", + "member": "Customer member", + "recognized": "Recognized", + "asserted": "Asserted", + "view": "View", + "viewEntity": "View {{entity}}" + }, "drawer": { "secondsSuffix": "s", "agentCountSuffix": " Agent", diff --git a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts index 404bb480955..72c5b69ba74 100644 --- a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -33319,10 +33319,16 @@ export interface components { * @description The levels a request is charged to. A request that named only an organization alias has no team or member. */ CustomerHierarchyAuth: { + /** Customer Member Alias */ + customer_member_alias?: string | null; /** Customer Member Id */ customer_member_id?: string | null; + /** Customer Org Alias */ + customer_org_alias?: string | null; /** Customer Org Id */ customer_org_id: string; + /** Customer Team Alias */ + customer_team_alias?: string | null; /** Customer Team Id */ customer_team_id?: string | null; /** Scopes */ @@ -33412,6 +33418,8 @@ export interface components { * @description One level of the customer hierarchy a request runs under, with the limits its budget row grants. */ CustomerScopeAuth: { + /** Alias */ + alias?: string | null; /** Allowed Models */ allowed_models?: string[] | null; /** diff --git a/tests/unit/proxy/auth/test_customer_hierarchy_auth.py b/tests/unit/proxy/auth/test_customer_hierarchy_auth.py index f313050cd40..70b539e5d70 100644 --- a/tests/unit/proxy/auth/test_customer_hierarchy_auth.py +++ b/tests/unit/proxy/auth/test_customer_hierarchy_auth.py @@ -178,6 +178,24 @@ def _row( } +def test_customer_display_names_come_from_the_resolved_rows() -> None: + hierarchy: Final = customer_hierarchy_from_member_row( + { + "customer_member_id": "customer-member-1", + "alias": "Customer member display", + "parent_team_ids": ["customer-team-1"], + "teams": [{"customer_team_id": "customer-team-1", "alias": "Customer team display"}], + "parent_org": {"customer_org_id": "customer-org-1", "alias": "Customer org display"}, + }, + NOW, + ) + assert (hierarchy.customer_org_alias, hierarchy.customer_team_alias, hierarchy.customer_member_alias) == ( + "Customer org display", + "Customer team display", + "Customer member display", + ) + + def _scopes(hierarchy: CustomerHierarchyAuth) -> Mapping[str, CustomerScopeAuth]: return {scope.key: scope for scope in hierarchy.scopes} diff --git a/tests/unit/proxy/auth/test_user_api_key_auth.py b/tests/unit/proxy/auth/test_user_api_key_auth.py index bef2b08d2a9..8e69a8a5fdc 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth.py @@ -18,6 +18,12 @@ import litellm.proxy import litellm.proxy.proxy_server from litellm._logging import verbose_proxy_logger from litellm.proxy._types import LiteLLM_AccessGroupTable, LiteLLM_UserTable, LitellmUserRoles, ProxyException +from litellm.proxy.auth.customer_hierarchy_auth import ( + CustomerBudgetExceededError, + customer_member_cache_key, + customer_org_alias_cache_key, + resolve_customer_hierarchy, +) from litellm.proxy.auth.user_api_key_auth import ( UserAPIKeyAuth, _authorize_authenticated_request, @@ -25,7 +31,119 @@ from litellm.proxy.auth.user_api_key_auth import ( user_api_key_auth, ) from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache +from litellm.types.proxy.auth.customer_hierarchy import CustomerHierarchyAuth, CustomerRequestIdentity, CustomerScopeAuth from tests._master_key import MASTER_KEY +from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "denial,code", + ( + ("scope", "403"), + ("mismatch", "403"), + ("unknown", "401"), + ("model", "403"), + ("model_budget", "403"), + ("budget", "422"), + ), +) +async def test_customer_identity_is_retained_only_after_ownership_authorization( + monkeypatch: pytest.MonkeyPatch, denial: str, code: str +) -> None: + server: Final = litellm.proxy.proxy_server + cache: Final = UserApiKeyCache() + hierarchy: Final = CustomerHierarchyAuth( + customer_org_id="customer-org-b", + customer_org_alias="customer-b", + customer_team_id="customer-team-b", + customer_member_id="customer-member-b", + scopes=( + CustomerScopeAuth( + key="customer_org", + entity_id="customer-org-b", + spend=1, + max_budget=10 if denial == "model" else 0, + allowed_models=("other-model",) if denial in ("model", "model_budget") else (), + ), + ), + ) + await cache.async_set_cache(key=customer_member_cache_key("customer-member-b"), value=hierarchy) + await cache.async_set_cache( + key=customer_org_alias_cache_key("customer-a"), + value=CustomerHierarchyAuth(customer_org_id="customer-org-a", customer_org_alias="customer-a", scopes=()), + ) + database: Final = MagicMock() + database.db.litellm_customerorganizationtable.find_many = AsyncMock(return_value=[]) + monkeypatch.setattr(server, "prisma_client", database) + monkeypatch.setattr(server, "user_api_key_cache", cache) + monkeypatch.setattr(server, "llm_router", None) + monkeypatch.setattr(server, "general_settings", {}) + identity: Final = UserAPIKeyAuth( + token="verified-customer-key", + metadata={ + "customer_org_id": "customer-org-b" if denial in ("budget", "model", "model_budget") else "customer-org-a" + }, + customer_hierarchy=hierarchy, + ) + headers: Final = ((b"x-litellm-customer-member-id", b"customer-member-b"),) + ( + ((b"x-litellm-customer-org-alias", b"customer-a" if denial == "mismatch" else b"unknown-customer"),) + if denial in ("mismatch", "unknown") + else () + ) + request: Final = StarletteRequest({"type": "http", "method": "POST", "path": "/v1/responses", "headers": headers}) + with pytest.raises(ProxyException) as denied: + await _authorize_authenticated_request( + user_api_key_auth_obj=identity, + request=request, + request_data={"model": "qa-model", "input": "Hello"}, + route="/v1/responses", + api_key="verified-customer-key", + ) + assert denied.value.code == code + assert identity.customer_hierarchy == (hierarchy if denial == "budget" else None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("route", ("/v1/responses", "/v1/chat/completions", "/v1/messages")) +async def test_budget_denial_persists_the_hierarchy_used_by_the_common_resolver( + monkeypatch: pytest.MonkeyPatch, route: str +) -> None: + server: Final = litellm.proxy.proxy_server + cache: Final = UserApiKeyCache() + hierarchy: Final = CustomerHierarchyAuth( + customer_org_id="customer-org-b", + customer_member_id="customer-member-b", + scopes=(CustomerScopeAuth(key="customer_org", entity_id="customer-org-b", spend=1, max_budget=0.5),), + ) + await cache.async_set_cache(key=customer_member_cache_key("customer-member-b"), value=hierarchy) + store: Final = FakeSpendCounters().store() + await store.increment("spend:customer_org:customer-org-b:lifetime", 2) + database: Final = MagicMock() + monkeypatch.setattr(server, "prisma_client", database) + monkeypatch.setattr(server, "user_api_key_cache", cache) + monkeypatch.setattr(server, "spend_counter_store", store) + monkeypatch.setattr(server, "llm_router", None) + monkeypatch.setattr(server, "general_settings", {}) + with pytest.raises(CustomerBudgetExceededError) as resolved: + await resolve_customer_hierarchy( + CustomerRequestIdentity(customer_member_id="customer-member-b"), database, cache, None, "qa-model", route + ) + identity: Final = UserAPIKeyAuth(token="verified-customer-key") + request: Final = StarletteRequest( + { + "type": "http", + "method": "POST", + "path": route, + "headers": ((b"x-litellm-customer-member-id", b"customer-member-b"),), + } + ) + with pytest.raises(ProxyException) as denied: + await _authorize_authenticated_request( + identity, request, {"model": "qa-model", "input": "Hello"}, route, "verified-customer-key" + ) + assert denied.value.code == "422" + assert identity.customer_hierarchy == resolved.value.customer_hierarchy class Request: diff --git a/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py index 338f3086e6c..20abaf578df 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py @@ -70,6 +70,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import ( spend_log_row_with_retained_metadata, ) from litellm.proxy.utils import hash_token +from litellm.types.proxy.auth.customer_hierarchy import CustomerHierarchyAuth from litellm.types.passthrough_endpoints.pass_through_endpoints import ( SETTLES_BACKGROUND_RESPONSE_KWARG, BackgroundResponseSettlement, @@ -88,6 +89,78 @@ from litellm.types.utils import ( ) +@pytest.mark.parametrize("bucket", ("metadata", "litellm_metadata")) +@pytest.mark.parametrize("call_type", ("acompletion", "aresponses", "anthropic_messages")) +@pytest.mark.parametrize("failed", (False, True)) +def test_spend_log_retains_resolved_customer_identity(bucket: str, call_type: str, failed: bool) -> None: + hierarchy: Final = CustomerHierarchyAuth( + customer_org_id="customer-org-1", + customer_org_alias="Customer org display", + customer_team_id="customer-team-1", + customer_team_alias="Customer team display", + customer_member_id="customer-member-1", + customer_member_alias="Customer member display", + scopes=(), + ) + auth: Final = UserAPIKeyAuth(customer_hierarchy=hierarchy) + stamped: Final = LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(auth) + now: Final = datetime.datetime(2026, 1, 1, tzinfo=timezone.utc) + payload: Final = get_logging_payload( + kwargs={ + "model": "test-model", + "call_type": call_type, + "litellm_params": {bucket: {**stamped, "status": "failure" if failed else "success"}}, + }, + response_obj=ValueError("request denied") if failed else {}, + start_time=now, + end_time=now, + ) + stored: Final = TypeAdapter(Mapping[str, object]).validate_json(payload["metadata"]) + expected: Final = { + "user_api_key_customer_org_id": "customer-org-1", + "user_api_key_customer_org_alias": "Customer org display", + "user_api_key_customer_team_id": "customer-team-1", + "user_api_key_customer_team_alias": "Customer team display", + "user_api_key_customer_member_id": "customer-member-1", + "user_api_key_customer_member_alias": "Customer member display", + } + assert {key: stored.get(key) for key in expected} == expected + assert stored["status"] == ("failure" if failed else "success") + + +@pytest.mark.parametrize("hierarchy", (None, CustomerHierarchyAuth(customer_org_id="customer-org-only", scopes=()))) +def test_spend_log_customer_identity_without_a_customer_member(hierarchy: CustomerHierarchyAuth | None) -> None: + metadata: Final = LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key( + UserAPIKeyAuth(customer_hierarchy=hierarchy) + ) + stored: Final = _get_spend_logs_metadata({**metadata}) + assert stored.get("user_api_key_customer_org_id") == (hierarchy.customer_org_id if hierarchy else None) + assert stored.get("user_api_key_customer_member_id") is None + assert stored.get("user_api_key_customer_team_id") is None + assert stored.get("user_api_key_customer_org_alias") is None + + +@pytest.mark.parametrize("value", (None, 42, {"invalid": True}, " ")) +def test_spend_log_omits_invalid_customer_identity(value: object) -> None: + stored: Final = _get_spend_logs_metadata({"user_api_key_customer_org_id": value}) + assert stored.get("user_api_key_customer_org_id") is None + + +@pytest.mark.parametrize("metadata", (None, {}, {"user_api_key_user_id": "user"})) +def test_spend_log_without_customer_identity_preserves_legacy_metadata(metadata: dict[str, str] | None) -> None: + fields: Final = frozenset( + ( + "user_api_key_customer_org_id", + "user_api_key_customer_org_alias", + "user_api_key_customer_team_id", + "user_api_key_customer_team_alias", + "user_api_key_customer_member_id", + "user_api_key_customer_member_alias", + ) + ) + assert fields.isdisjoint(_get_spend_logs_metadata(metadata)) + + def _get_additional_usage_values_for_usage(usage: litellm.Usage) -> dict: payload = get_logging_payload( kwargs={ @@ -7568,6 +7641,12 @@ def test_spend_logs_payload(model_id: str | None): expected_metadata_keys: Final = SpendLogsMetadata.__annotations__.keys() - { "is_silent_experiment", "upstream_provider", + "user_api_key_customer_org_id", + "user_api_key_customer_org_alias", + "user_api_key_customer_team_id", + "user_api_key_customer_team_alias", + "user_api_key_customer_member_id", + "user_api_key_customer_member_alias", } assert "metadata" in payload