feat(otel/v2): disclose resolved trace destinations on team and org info

The team and org pages showed different Logging Exporters lists per role: the
(via scope) badges were derived client-side from GET /credentials, which is
proxy-admin only, so non-admin viewers saw only the identity's own assignments.

Add resolved_logging_exporters to the /team/info and /organization/info
responses: the destination names that will receive the identity's traces,
computed server-side with the same selection the request-time resolver uses
(access grants the identity AND auto_enable or named). Names only; endpoints,
headers, and the access map stay proxy-admin information. The UI renders the
badges from this field, deleting the client-side credentials derivation, so
every role sees the identical list.
This commit is contained in:
Yucheng Zhu 2026-07-28 13:39:08 -07:00
parent 9043a39327
commit 670396ee95
9 changed files with 121 additions and 40 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<OrganizationInfoProps> = ({
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<string[]>(() => {
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);

View file

@ -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<string, any>;
guardrails?: string[];
policies?: string[];
@ -230,25 +230,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
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<string[]>(() => {
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]);

View file

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