mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
9043a39327
commit
670396ee95
9 changed files with 121 additions and 40 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") == ()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue