diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 25c5f2adf73..63d5bd19081 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2860,6 +2860,9 @@ class LiteLLM_OrganizationTableWithMembers(LiteLLM_OrganizationTable): litellm_budget_table: Optional[LiteLLM_BudgetTable] = None created_at: datetime updated_at: datetime + # Destination names that will receive this org's traces (own logging_exporters + # plus auto-enabled destinations whose access grants the org). Names only. + resolved_logging_exporters: Sequence[str] | None = None class NewOrganizationResponse(LiteLLM_OrganizationTable): @@ -3874,6 +3877,9 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable): access_group_models: Optional[List[str]] = None access_group_mcp_server_ids: Optional[List[str]] = None access_group_agent_ids: Optional[List[str]] = None + # Destination names that will receive this team's traces (own logging_exporters + # plus auto-enabled destinations whose access grants the team). Names only. + resolved_logging_exporters: Sequence[str] | None = None class TeamInfoResponseObject(TypedDict): diff --git a/litellm/proxy/management_endpoints/logging_exporter_access.py b/litellm/proxy/management_endpoints/logging_exporter_access.py index 6c029034a1f..319fb1c2924 100644 --- a/litellm/proxy/management_endpoints/logging_exporter_access.py +++ b/litellm/proxy/management_endpoints/logging_exporter_access.py @@ -11,8 +11,11 @@ scope is the given set of team ids and org ids. The resolver passes a one-element scope built with ``identity_scope``. """ +from collections.abc import Sequence + from pydantic import ValidationError +import litellm from litellm.models.credentials import CredentialAccess, CredentialInfo @@ -41,6 +44,32 @@ def identity_scope(team_id: str | None, org_id: str | None) -> tuple[frozenset[s ) +def resolved_logging_exporter_names( + assigned: Sequence[str] | None, + team_id: str | None, + org_id: str | None, +) -> tuple[str, ...]: + """Destination names that will receive this identity's traces, for disclosure on + the team/org info pages. + + Mirrors the request-time resolver's selection: a logging destination is included + when its ``access`` grants the identity AND it is either ``auto_enable`` or named + in ``assigned`` (the identity's own ``logging_exporters``). Names only; endpoints, + headers, and the access map itself stay proxy-admin information. + """ + team_ids, org_ids = identity_scope(team_id, org_id) + own = frozenset(str(name) for name in (assigned or ())) + selected = tuple( + credential.credential_name + for credential in litellm.credential_list + if (info := parse_credential_info(credential.credential_info)) is not None + and info.credential_type == "logging" + and access_grants(info.access, team_ids, org_ids) + and (info.auto_enable or credential.credential_name in own) + ) + return tuple(dict.fromkeys(selected)) + + def access_grants( access: CredentialAccess | None, team_ids: frozenset[str], diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 1a4129ec5ee..97707f1d028 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -36,6 +36,9 @@ from litellm.proxy.management_endpoints.budget_management_endpoints import ( update_budget, ) from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity +from litellm.proxy.management_endpoints.logging_exporter_access import ( + resolved_logging_exporter_names, +) from litellm.proxy.management_endpoints.common_utils import ( _set_object_metadata_field, _user_has_admin_view, @@ -1120,6 +1123,11 @@ async def info_organization( raise HTTPException(status_code=404, detail={"error": "Organization not found"}) response_pydantic_obj = LiteLLM_OrganizationTableWithMembers.model_validate(response.model_dump()) + response_pydantic_obj.resolved_logging_exporters = resolved_logging_exporter_names( + response_pydantic_obj.logging_exporters, + None, + organization_id, + ) return response_pydantic_obj diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 92a6ceb6078..ef4beaaea51 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -78,6 +78,9 @@ from litellm.proxy.auth.auth_checks import ( from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch +from litellm.proxy.management_endpoints.logging_exporter_access import ( + resolved_logging_exporter_names, +) from litellm.proxy.management_endpoints.common_utils import ( _check_passthrough_routes_caller_permission, _is_user_org_admin_for_team, @@ -3659,6 +3662,12 @@ async def team_info( # Resolve resources inherited from access groups await _resolve_team_access_group_resources(_team_info) + _team_info.resolved_logging_exporters = resolved_logging_exporter_names( + _team_info.logging_exporters, + team_id, + _team_info.organization_id, + ) + response_object = TeamInfoResponseObject( team_id=team_id, team_info=_team_info, diff --git a/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_access.py b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_access.py index 7f83b3eb52d..63afec043e4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_access.py +++ b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_access.py @@ -10,11 +10,13 @@ import sys sys.path.insert(0, os.path.abspath("../../../..")) -from litellm.models.credentials import CredentialAccess, CredentialInfo +import litellm +from litellm.models.credentials import CredentialAccess, CredentialInfo, CredentialItem from litellm.proxy.management_endpoints.logging_exporter_access import ( access_grants, identity_scope, parse_credential_info, + resolved_logging_exporter_names, ) @@ -161,3 +163,51 @@ def test_identity_scope_empty_for_none(): teams, orgs = identity_scope(None, None) assert teams == frozenset() assert orgs == frozenset() + + +# --- resolved_logging_exporter_names: the /team/info + /organization/info disclosure -- + + +def _cred(name, access=None, auto=False, ctype="logging"): + info = {"credential_type": ctype, "auto_enable": auto} + if access is not None: + info["access"] = access + return CredentialItem(credential_name=name, credential_values={}, credential_info=info) + + +def test_resolved_names_mirror_the_resolver(monkeypatch): + """Included: auto+granted, named+granted. Excluded: granted-but-manual-unnamed, + named-but-not-granted, empty-access even with auto, provider credentials.""" + monkeypatch.setattr( + litellm, + "credential_list", + [ + _cred("auto-team", access={"teams": ["t1"]}, auto=True), + _cred("manual-team", access={"teams": ["t1"]}, auto=False), + _cred("named-manual", access={"teams": ["t1"]}, auto=False), + _cred("named-ungranted", access={"teams": ["other"]}, auto=False), + _cred("empty-auto", auto=True), + _cred("global-auto", access={"global": True}, auto=True), + _cred("org-auto", access={"orgs": ["o1"]}, auto=True), + _cred("provider", access={"global": True}, auto=True, ctype=None), + ], + ) + names = resolved_logging_exporter_names(["named-manual", "named-ungranted"], "t1", "o1") + assert names == ("auto-team", "named-manual", "global-auto", "org-auto") + + +def test_resolved_names_empty_scope_gets_global_only(monkeypatch): + monkeypatch.setattr( + litellm, + "credential_list", + [ + _cred("global-auto", access={"global": True}, auto=True), + _cred("team-auto", access={"teams": ["t1"]}, auto=True), + ], + ) + assert resolved_logging_exporter_names(None, None, None) == ("global-auto",) + + +def test_resolved_names_empty_registry(monkeypatch): + monkeypatch.setattr(litellm, "credential_list", []) + assert resolved_logging_exporter_names(["anything"], "t1", "o1") == () diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 9a16d7fb281..9ccda47c1fd 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -210,6 +210,7 @@ export interface Organization { users: any[] | null; members: any[] | null; object_permission?: ObjectPermission | null; + resolved_logging_exporters?: string[] | null; } export interface CredentialItem { diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 90a9ee8f45a..ae9916d716c 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -25,7 +25,6 @@ import { import ObjectPermissionsView from "../object_permissions_view"; import MemberModal from "../team/EditMembership"; import { loggingExportersOf } from "../logging_credentials/loggingExportersOf"; -import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import { OrgSettingsForm } from "./org-settings/OrgSettingsForm"; interface OrganizationInfoProps { @@ -59,25 +58,13 @@ const OrganizationInfoView: React.FC = ({ const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); - // Destinations whose credential_info.access targets THIS org (or is global). - // Rendered alongside the org's own metadata.logging_exporters so the Logging - // Exporters card reflects BOTH routing directions, matching the resolver's - // union at request time. GET /credentials is proxy-admin only, so the fetch is - // skipped (and the scoped list stays empty) for other roles. - const { data: orgCredentialsData } = useCredentials(is_proxy_admin); + // Destinations that will receive this org's traces, resolved server-side by + // /organization/info (own logging_exporters plus auto-enabled destinations whose + // access grants the org). Names only; identical for every role. const scopedExportersForOrg = useMemo(() => { - const orgId = orgData?.organization_id; - if (orgId == null) return []; - return (orgCredentialsData?.credentials ?? []) - .filter((c) => c.credential_info?.credential_type === "logging") - .filter((c) => { - const access = c.credential_info?.access; - if (!access) return false; - if (access.global === true) return true; - return Array.isArray(access.orgs) && access.orgs.includes(orgId); - }) - .map((c) => c.credential_name); - }, [orgCredentialsData?.credentials, orgData?.organization_id]); + const own = new Set(loggingExportersOf(orgData)); + return (orgData?.resolved_logging_exporters ?? []).filter((name) => !own.has(name)); + }, [orgData]); const loggingExporterBadges = useMemo(() => { const own = loggingExportersOf(orgData); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 8ccda890234..4c10a53c609 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -18,7 +18,7 @@ import { useGuardrails, GuardrailListItem } from "@/app/(dashboard)/hooks/guardr import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; import type { ObjectPermission } from "@/components/object_permission_types"; -import { canReadCredentialsRole, isProxyAdminRole } from "@/utils/roles"; +import { isProxyAdminRole } from "@/utils/roles"; import { EditOutlined, GlobalOutlined, @@ -43,7 +43,6 @@ import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSel import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key"; import GuardrailSettingsView from "../GuardrailSettingsView"; import LoggingSettingsView from "../logging_settings_view"; -import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; import { ModelSelect } from "../ModelSelect/ModelSelect"; @@ -119,6 +118,7 @@ export interface TeamData { access_group_models?: string[]; access_group_mcp_server_ids?: string[]; access_group_agent_ids?: string[]; + resolved_logging_exporters?: string[] | null; router_settings?: Record; guardrails?: string[]; policies?: string[]; @@ -230,25 +230,14 @@ const TeamInfoView: React.FC = ({ const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam; - // Destinations whose credential_info.access targets this team (or its org, or - // global). Rendered alongside the team's own metadata.logging_exporters so the - // Logging Exporters card reflects BOTH routing directions, matching the - // resolver's union at request time. GET /credentials is proxy-admin only, so - // the fetch is skipped (and the scoped list stays empty) for other roles. - const { data: scopedCredentialsData } = useCredentials(canReadCredentialsRole(userRole)); + // Destinations that will receive this team's traces, resolved server-side by + // /team/info (own logging_exporters plus auto-enabled destinations whose access + // grants the team). Names only, visible to every team viewer; the badge list is + // identical for every role because no client-side credentials read is involved. const scopedExportersForTeam = useMemo(() => { - const orgId = teamData?.team_info?.organization_id ?? null; - return (scopedCredentialsData?.credentials ?? []) - .filter((c) => c.credential_info?.credential_type === "logging") - .filter((c) => { - const access = c.credential_info?.access; - if (!access) return false; - if (access.global === true) return true; - if (Array.isArray(access.teams) && access.teams.includes(teamId)) return true; - return Array.isArray(access.orgs) && orgId != null && access.orgs.includes(orgId); - }) - .map((c) => c.credential_name); - }, [scopedCredentialsData?.credentials, teamId, teamData?.team_info?.organization_id]); + const own = new Set(loggingExportersOf(teamData?.team_info)); + return (teamData?.team_info?.resolved_logging_exporters ?? []).filter((name) => !own.has(name)); + }, [teamData?.team_info]); const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]); const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]); diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e0bd3ceebc5..709dff0c672 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -25776,6 +25776,8 @@ export interface components { organization_alias?: string | null; /** Organization Id */ organization_id?: string | null; + /** Resolved Logging Exporters */ + resolved_logging_exporters?: string[] | null; /** * Spend * @default 0