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>
This commit is contained in:
ishaan-j-berri 2026-11-14 01:39:50 +08:00 • committed by GitHub
parent 2a4e90c35e
commit a60e16b87d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 644 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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=(),

View file

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

View file

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

View file

@ -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<string, unknown> | 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)),
};
}

View file

@ -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) => <section>{children}</section>,
CardHeader: ({ children }: PropsWithChildren) => <header>{children}</header>,
CardTitle: ({ children }: PropsWithChildren) => <div>{children}</div>,
CardContent: ({ children }: PropsWithChildren) => <div>{children}</div>,
}));
vi.mock("./TruncatedValue", () => ({
TruncatedValue: ({ value }: { value: string }) => <button aria-label={`Copy ${value}`}>{value}</button>,
}));
describe("CustomerSection", () => {
it("summarizes names when collapsed and links each recognized customer scope when expanded", () => {
render(
<CustomerSection
metadata={{
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",
}}
/>,
);
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(<CustomerSection metadata={{ user_api_key_customer_org_id: "customer-org-only" }} />);
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(<CustomerSection metadata={{ user_api_key_customer_member_id: "historical-customer-member" }} />);
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(<CustomerSection endUser="legacy-customer" />);
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(<CustomerSection metadata={metadata} />);
expect(screen.queryByText("Customer")).not.toBeInTheDocument();
});
});

View file

@ -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<string, unknown> | 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 (
<Card>
<CardContent className="py-0">
<Accordion>
<AccordionItem value="customer">
<AccordionTrigger className="items-center gap-3 hover:no-underline">
<span className="shrink-0">{t("logs.detail.customer.title")}</span>
<span className="grid min-w-0 flex-1 grid-cols-2 gap-3">
{summary.map((row) => (
<span key={row.scope} className="min-w-0 text-xs">
<span className="block whitespace-nowrap text-[11px] text-muted-foreground font-normal">
{label(row)}
</span>
<span className="block truncate" title={row.name}>
{row.name}
</span>
</span>
))}
</span>
<Badge variant={recognized ? "secondary" : "outline"}>
{t(`logs.detail.customer.${recognized ? "recognized" : "asserted"}`)}
</Badge>
</AccordionTrigger>
<AccordionContent>
<dl className="space-y-4 pt-2">
{rows.map((row) => (
<div key={row.scope} className="grid grid-cols-[8rem_minmax(0,1fr)_auto] items-start gap-3 text-sm">
<dt className="text-muted-foreground">{label(row)}</dt>
<dd className="min-w-0 space-y-1">
{row.name !== row.id && <div className="break-words font-medium">{row.name}</div>}
{row.id && (
<div className="text-muted-foreground">
<TruncatedValue value={row.id} maxWidth={250} />
</div>
)}
</dd>
<dd>
{href(row) && (
<Button
nativeButton={false}
role="link"
render={<a href={href(row)} />}
variant="outline"
size="xs"
aria-label={t("logs.detail.customer.viewEntity", { entity: label(row) })}
>
<ExternalLink />
{t("logs.detail.customer.view")}
</Button>
)}
</dd>
</div>
))}
</dl>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
);
}

View file

@ -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 */}
<MetricsSection logEntry={logEntry} metadata={metadata} />
<CustomerSection metadata={metadata} endUser={logEntry.end_user} />
{/* Cost Breakdown */}
<CostBreakdownViewer
costBreakdown={metadata?.cost_breakdown}

View file

@ -35,6 +35,30 @@ const logEntry = (overrides: Partial<LogEntry>): 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(
<DataTable

View file

@ -14,6 +14,7 @@ import { copyToClipboard, getSpendString } from "@/utils/dataUtils";
import { getProviderLogoAndName } from "../../provider_info_helpers";
import { getBatchIdFromRequestId, getBatchRequestCounts, isBatchCallType } from "../batchLogUtils";
import type { LogEntry } from "../types";
import { customerLogIdentity } from "../customerIdentity";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants";
import { AgentBadge, AgentIcon, BatchBadge, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
@ -304,6 +305,26 @@ export const getRequestLogsTableColumns = (
enableSorting: false,
cell: ({ row }) => <TruncatedText value={readMetaString(row.original.metadata, "user_api_key_team_alias")} />,
},
{
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 <span className="text-muted-foreground">-</span>;
return (
<div className="flex flex-col gap-0.5">
{summary.map((customer) => (
<TruncatedText key={customer.scope} value={customer.name} tooltip={customer.name} />
))}
<Badge variant={recognized ? "secondary" : "outline"} className="mt-0.5 text-[10px]">
{t(`logs.detail.customer.${recognized ? "recognized" : "asserted"}`)}
</Badge>
</div>
);
},
},
{
id: "key_hash",
header: t("logs.request.columns.keyHash"),

View file

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

View file

@ -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;
/**

View file

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

View file

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

View file

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