mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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:
parent
2a4e90c35e
commit
a60e16b87d
19 changed files with 644 additions and 20 deletions
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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=(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
8
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue