diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 577f8000b60..f239426246b 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -149,7 +149,6 @@ _ENABLE_TEAM_STALE_ALIAS_BYPASS: bool | None = None if TYPE_CHECKING: from litellm.integrations.otel.model.destination import OtelDestination - from litellm.models.credentials import CredentialItem from litellm.proxy.proxy_server import ProxyConfig as _ProxyConfig from litellm.types.proxy.policy_engine import PolicyMatchContext from litellm.types.utils import OtelDestinationParams @@ -751,6 +750,10 @@ async def _resolve_logging_exporters( ``access.global``). Each survivor is built via ``destination_for_credential`` and deduped on (endpoint, headers, resource attributes). Returns ([], []) when nothing is selected (default-deny). + + The caller's org is resolved only when some destination is org-scoped: the fallback + loads the team, which on a cache miss is a Prisma read on the authentication path, + and it cannot change the outcome when no ``access`` names an org. """ from litellm.integrations.otel.model.config import is_otel_v2_enabled from litellm.proxy.management_endpoints.logging_exporter_access import ( @@ -766,26 +769,26 @@ async def _resolve_logging_exporters( if not is_otel_v2_enabled(): return (), () - if not any( - (info := parse_credential_info(credential.credential_info)) is not None and info.credential_type == "logging" + logging_credentials = tuple( + (credential, info) for credential in litellm.credential_list - ): + if (info := parse_credential_info(credential.credential_info)) is not None and info.credential_type == "logging" + ) + if not logging_credentials: return (), () team_id = user_api_key_dict.team_id - org_id = await _effective_org_id(user_api_key_dict) + org_id = ( + await _effective_org_id(user_api_key_dict) + if any(info.access is not None and info.access.orgs for _, info in logging_credentials) + else None + ) team_ids, org_ids = identity_scope(team_id, org_id) - def _selected(credential: "CredentialItem") -> bool: - info = parse_credential_info(credential.credential_info) - if info is None or info.credential_type != "logging": - return False - return access_grants(info.access, team_ids, org_ids) - built = tuple( result - for credential in litellm.credential_list - if _selected(credential) + for credential, info in logging_credentials + if access_grants(info.access, team_ids, org_ids) if (result := destination_for_credential(credential)) is not None ) deduped = { diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index cac93b88cd0..63c122f36d7 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -6724,9 +6724,11 @@ async def test_resolve_logging_exporters_short_circuits_without_destinations(mon @pytest.mark.asyncio -async def test_resolve_logging_exporters_runs_lookup_when_a_destination_exists(monkeypatch): +async def test_resolve_logging_exporters_resolves_a_global_destination_without_an_org_lookup(monkeypatch): """The short-circuit must not skip resolution when a destination exists: a global - destination is still resolved for a team-scoped key, and the org lookup runs.""" + destination is still resolved for a team-scoped key. No destination is org-scoped + here, so the org fallback (a team load, and on a cache miss a Prisma read on the + auth path) cannot change the outcome and must not run.""" from litellm.integrations.otel.model.config import is_otel_v2_enabled from litellm.proxy import litellm_pre_call_utils as pcu @@ -6755,10 +6757,46 @@ async def test_resolve_logging_exporters_runs_lookup_when_a_destination_exists(m key = UserAPIKeyAuth(api_key="k", team_id="t1") destinations, backends = await pcu._resolve_logging_exporters(key) - assert lookups["org"] == 1 # a destination exists, so the resolver runs the lookup + assert lookups["org"] == 0 # nothing is org-scoped, so the lookup is skipped assert "generic" in backends # global access grants the team key +@pytest.mark.asyncio +async def test_resolve_logging_exporters_runs_the_org_lookup_for_an_org_scoped_destination(monkeypatch): + """An org-scoped destination is invisible without the caller's org, so the fallback + still runs; a team key carrying no org_id resolves it through its team.""" + from litellm.integrations.otel.model.config import is_otel_v2_enabled + from litellm.proxy import litellm_pre_call_utils as pcu + + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + is_otel_v2_enabled.cache_clear() + + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="d-org", + credential_values={"otel_endpoint": "https://collector/v1/traces"}, + credential_info={"credential_type": "logging", "description": "generic", "access": {"orgs": ["o1"]}}, + ) + ], + ) + lookups = {"org": 0} + + async def _spy_effective_org_id(user_api_key_dict): + lookups["org"] += 1 + return "o1" + + monkeypatch.setattr(pcu, "_effective_org_id", _spy_effective_org_id) + + key = UserAPIKeyAuth(api_key="k", team_id="t1") + destinations, backends = await pcu._resolve_logging_exporters(key) + + assert lookups["org"] == 1 # an org-scoped destination needs the caller's org + assert "generic" in backends # and the org grant then selects it + + @pytest.mark.asyncio async def test_resolve_logging_exporters_noop_when_flag_off(monkeypatch): """LITELLM_OTEL_V2 is the sole activation gate: with the flag off, the resolver