From f4fd544c5c0fdd85cecc4793e4c455c98a48a27f Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Thu, 30 Jul 2026 16:10:15 -0700 Subject: [PATCH] fix(otel/v2): keep credential + preset + activation behavior additive vs base Three of the review findings, each restoring a pre-PR behavior the v2 work changed: Provider credentials are merged in memory again on a partial PATCH. _sync_in_memory_credential now always _merge_credential_info, so a PATCH of only description keeps custom_llm_provider in litellm.credential_list rather than replacing it (which 401'd the credential until the reload tick). The phoenix, arize and agentops presets append their global exporter unconditionally again, so a zero-credential deployment (local Phoenix on localhost:6006, no-auth self-hosted arize) keeps exporting exactly as before instead of dropping the exporter when no env credentials are set. LITELLM_OTEL_V2 is the sole activation gate again. Registering an admin-owned destination no longer flips a v1 deployment onto v2 with the flag off; the resolver no-ops and _maybe_construct_otel_v2 returns None when the flag is off, so the flag-off + destination orphaned-trace configuration can't arise and an existing v1 deployment is unaffected by merely registering a credential. --- litellm/integrations/otel/presets/agentops.py | 16 ++---- litellm/integrations/otel/presets/arize.py | 16 ++---- litellm/integrations/otel/presets/phoenix.py | 32 ++++------- litellm/litellm_core_utils/litellm_logging.py | 24 ++++---- .../proxy/credential_endpoints/endpoints.py | 16 +++--- litellm/proxy/litellm_pre_call_utils.py | 7 +++ .../integrations/otel/test_otel_v2_presets.py | 48 +++++++--------- .../test_litellm_logging.py | 57 +++++++++---------- .../credential_endpoints/test_endpoints.py | 31 ++++++++++ .../proxy/test_litellm_pre_call_utils.py | 52 +++++++++++++++++ 10 files changed, 177 insertions(+), 122 deletions(-) diff --git a/litellm/integrations/otel/presets/agentops.py b/litellm/integrations/otel/presets/agentops.py index f4bef67d092..3ce3198f273 100644 --- a/litellm/integrations/otel/presets/agentops.py +++ b/litellm/integrations/otel/presets/agentops.py @@ -51,16 +51,12 @@ def agentops_preset( settings = _AgentOpsSettings() base = config_overrides or OpenTelemetryV2Config() global_exporter = ( - ( - ExporterSpec( - kind=_AGENTOPS_EXPORTER_KIND, - endpoint=_AGENTOPS_ENDPOINT, - options={"api_key": settings.api_key}, - owner=ExporterOwner.AGENTOPS, - ), - ) - if settings.api_key - else () + ExporterSpec( + kind=_AGENTOPS_EXPORTER_KIND, + endpoint=_AGENTOPS_ENDPOINT, + options=({"api_key": settings.api_key} if settings.api_key else None), + owner=ExporterOwner.AGENTOPS, + ), ) return base.model_copy( update={ diff --git a/litellm/integrations/otel/presets/arize.py b/litellm/integrations/otel/presets/arize.py index c65ec1bf12f..622ba2f90da 100644 --- a/litellm/integrations/otel/presets/arize.py +++ b/litellm/integrations/otel/presets/arize.py @@ -29,16 +29,12 @@ def arize_preset( headers = _arize_headers(arize_cfg) base = config_overrides or OpenTelemetryV2Config() global_exporter = ( - ( - ExporterSpec( - kind=arize_cfg.protocol or "otlp_grpc", - endpoint=arize_cfg.endpoint or "https://otlp.arize.com/v1", - headers=headers, - owner=ExporterOwner.ARIZE_AX, - ), - ) - if headers - else () + ExporterSpec( + kind=arize_cfg.protocol or "otlp_grpc", + endpoint=arize_cfg.endpoint or "https://otlp.arize.com/v1", + headers=headers, + owner=ExporterOwner.ARIZE_AX, + ), ) return base.model_copy( update={ diff --git a/litellm/integrations/otel/presets/phoenix.py b/litellm/integrations/otel/presets/phoenix.py index ab7f7bdde02..8d2ceb94461 100644 --- a/litellm/integrations/otel/presets/phoenix.py +++ b/litellm/integrations/otel/presets/phoenix.py @@ -1,7 +1,5 @@ """Arize-Phoenix preset.""" -import os - from pydantic import AliasChoices, Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -25,33 +23,23 @@ class _PhoenixSettings(BaseSettings): ) -_PHOENIX_ENV_VARS = ( - "PHOENIX_API_KEY", - "PHOENIX_COLLECTOR_ENDPOINT", - "PHOENIX_COLLECTOR_HTTP_ENDPOINT", -) - - def phoenix_preset( *, config_overrides: OpenTelemetryV2Config | None = None, allow_missing_credentials: bool = False, ) -> OpenTelemetryV2Config: + cfg = _V1Phoenix.get_arize_phoenix_config() + headers = cfg.otlp_auth_headers if hasattr(cfg, "otlp_auth_headers") else None project_name = _PhoenixSettings().project_name base = config_overrides or OpenTelemetryV2Config() - if any(os.environ.get(v) for v in _PHOENIX_ENV_VARS): - cfg = _V1Phoenix.get_arize_phoenix_config() - headers = cfg.otlp_auth_headers if hasattr(cfg, "otlp_auth_headers") else None - global_exporter = ( - ExporterSpec( - kind=cfg.protocol if hasattr(cfg, "protocol") else "otlp_http", - endpoint=cfg.endpoint, - headers=headers, - owner=ExporterOwner.ARIZE_PHOENIX, - ), - ) - else: - global_exporter = () + global_exporter = ( + ExporterSpec( + kind=cfg.protocol if hasattr(cfg, "protocol") else "otlp_http", + endpoint=cfg.endpoint, + headers=headers, + owner=ExporterOwner.ARIZE_PHOENIX, + ), + ) return base.model_copy( update={ "exporters": [*base.exporters, *global_exporter], diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index dd3ba6eb456..e738de21f8e 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -4187,12 +4187,10 @@ def _has_admin_owned_logging_destination(callback_name: str) -> bool: """Whether an admin has registered a logging destination for this backend. Admin-owned trace destinations (``logging`` credentials, created from the UI) - are an OTEL v2 feature: the v2 logger fans a request's spans out to each - destination using that destination's own credentials. So when one exists for - ``callback_name`` the v2 logger must own the backend even if the global - ``LITELLM_OTEL_V2`` flag is off, otherwise activation falls back to the legacy - global logger, which ignores the per-destination credentials and exports with - whatever (often absent) global env credentials are set. + are an OTEL v2 feature, gated on the ``LITELLM_OTEL_V2`` flag like the rest of + v2. When the flag is on and one exists for ``callback_name``, the preset's + missing-global-credentials check is relaxed: the destination carries its own + credentials, so the v2 logger need not find global env credentials to build. """ import litellm @@ -4207,16 +4205,18 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list) -> Op """Build (or reuse) a single ``OpenTelemetryV2`` instance configured via the preset for ``callback_name`` when V2 owns this backend. - V2 owns the backend when the global ``LITELLM_OTEL_V2`` flag is on, or when an - admin-owned logging destination is registered for it (which is itself a V2-only - feature). Returns ``None`` otherwise, or when there's no preset registered for - ``callback_name`` — callers should then fall through to the legacy path. + The global ``LITELLM_OTEL_V2`` flag is the sole activation gate: V2 owns the + backend only when the flag is on. Returns ``None`` otherwise, or when there's no + preset registered for ``callback_name`` — callers should then fall through to the + legacy path. A registered admin-owned destination does not by itself activate V2; + it only relaxes the preset's missing-global-credentials check (the destination + carries its own credentials). """ from litellm.integrations.otel.model.config import is_otel_v2_enabled - has_admin_dest = _has_admin_owned_logging_destination(callback_name) - if not is_otel_v2_enabled() and not has_admin_dest: + if not is_otel_v2_enabled(): return None + has_admin_dest = _has_admin_owned_logging_destination(callback_name) from litellm.integrations.otel.logger import OpenTelemetryV2 from litellm.integrations.otel.presets import PRESET_BY_CALLBACK diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 2769d991904..6dd613c6839 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -389,11 +389,12 @@ def _sync_in_memory_credential( """Mirror the DB write into ``litellm.credential_list``. Skips when the credential isn't resident in memory (e.g. created on - another scaled instance, restored from DB on the next reload). For a - logging destination the in-memory ``credential_info`` is merged - subfield-by-subfield via ``_merge_credential_info`` so a partial patch - can't clobber stored ``access`` subfields it didn't touch; a provider - credential keeps the base replace semantics. + another scaled instance, restored from DB on the next reload). The + in-memory ``credential_info`` is merged subfield-by-subfield via + ``_merge_credential_info`` for every credential, matching the pre-PR + in-memory semantics: a partial patch can't clobber stored keys it didn't + touch (``custom_llm_provider`` on a provider credential, ``access`` + subfields on a logging destination). """ existing_in_memory: CredentialItem | None = None for cred in litellm.credential_list: @@ -408,10 +409,7 @@ def _sync_in_memory_credential( in_memory_values.update(patch.credential_values) in_memory_info = dict(existing_in_memory.credential_info or {}) if patch.credential_info: - if is_logging_credential(existing_in_memory.credential_info): - _merge_credential_info(in_memory_info, patch.credential_info) - else: - in_memory_info = dict(patch.credential_info) + _merge_credential_info(in_memory_info, patch.credential_info) updated_in_memory = CredentialItem( credential_name=merged.credential_name, credential_values=in_memory_values, diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index e323f4ff20e..2544d24b583 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -634,6 +634,7 @@ async def _resolve_logging_exporters( (endpoint, headers, resource attributes). Returns ([], []) when nothing is selected (default-deny). """ + from litellm.integrations.otel.model.config import is_otel_v2_enabled from litellm.integrations.otel.presets.destinations import build_destination from litellm.proxy.management_endpoints.logging_exporter_access import ( access_grants, @@ -641,6 +642,12 @@ async def _resolve_logging_exporters( parse_credential_info, ) + # Admin-owned destinations are an OTEL v2 feature; the LITELLM_OTEL_V2 flag is the + # sole activation gate. With the flag off, registering a destination resolves to + # nothing (no backend is activated for the request) until the admin sets the flag. + 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" for credential in litellm.credential_list diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_presets.py b/tests/test_litellm/integrations/otel/test_otel_v2_presets.py index b14128bf384..c0ca48f09e7 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_presets.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_presets.py @@ -33,19 +33,21 @@ def test_agentops_preset_does_no_network_io(monkeypatch): assert spec.options == {"api_key": "ak-123"} # carried to the lazy exporter -def test_agentops_preset_without_key_omits_exporter(monkeypatch): - # With no API key the lazy-auth exporter has nothing to mint a JWT from, so - # the preset must not contribute a global exporter at all (it would otherwise - # fail every export). Admin-owned destinations carry their own credentials. +def test_agentops_preset_without_key_still_appends_exporter(monkeypatch): + # Additive parity with the pre-PR preset: a global agentops callback always + # contributes its exporter, with no api_key carried when none is configured. + # The JWT-minting exporter simply has nothing to mint until a key is set. monkeypatch.delenv("AGENTOPS_API_KEY", raising=False) cfg = agentops_preset() - assert [e for e in cfg.exporters if e.kind == _AGENTOPS_EXPORTER_KIND] == [] + specs = [e for e in cfg.exporters if e.kind == _AGENTOPS_EXPORTER_KIND] + assert len(specs) == 1 + assert specs[0].options is None -def test_arize_preset_without_credentials_omits_exporter(monkeypatch): - # Arize's OTLP ingestion rejects unauthenticated exports (PERMISSION_DENIED), - # so with no Arize credentials the preset must not contribute a credential-less - # global exporter pointed at the Arize cloud. +def test_arize_preset_without_credentials_still_appends_exporter(monkeypatch): + # Additive parity: a global arize callback always contributes its exporter at + # the configured (or default) endpoint, so a no-auth self-hosted collector + # reached via ARIZE_ENDPOINT keeps exporting exactly as before the PR. from litellm.integrations.otel.model.config import ExporterOwner from litellm.integrations.otel.presets.arize import arize_preset @@ -57,32 +59,22 @@ def test_arize_preset_without_credentials_omits_exporter(monkeypatch): ): monkeypatch.delenv(var, raising=False) cfg = arize_preset() - assert [e for e in cfg.exporters if e.owner == ExporterOwner.ARIZE_AX] == [] - - monkeypatch.setenv("ARIZE_SPACE_ID", "S") - monkeypatch.setenv("ARIZE_API_KEY", "K") - cfg = arize_preset() assert [e for e in cfg.exporters if e.owner == ExporterOwner.ARIZE_AX] != [] -def test_phoenix_preset_without_config_omits_exporter(monkeypatch): - # Unconfigured Phoenix defaults to http://localhost:6006; the preset must not - # contribute that exporter unless Phoenix is actually configured (cloud key or - # collector endpoint), so admin-owned-only setups don't export to localhost. +def test_phoenix_preset_without_config_appends_localhost_exporter(monkeypatch): + # Additive parity: unconfigured Phoenix defaults to http://localhost:6006 and + # the preset always contributes that exporter, so a local self-hosted Phoenix + # deployment with no env vars keeps receiving traces exactly as before the PR. from litellm.integrations.otel.model.config import ExporterOwner - from litellm.integrations.otel.presets.phoenix import ( - _PHOENIX_ENV_VARS, - phoenix_preset, - ) + from litellm.integrations.otel.presets.phoenix import phoenix_preset - for var in _PHOENIX_ENV_VARS: + for var in ("PHOENIX_API_KEY", "PHOENIX_COLLECTOR_ENDPOINT", "PHOENIX_COLLECTOR_HTTP_ENDPOINT"): monkeypatch.delenv(var, raising=False) cfg = phoenix_preset() - assert [e for e in cfg.exporters if e.owner == ExporterOwner.ARIZE_PHOENIX] == [] - - monkeypatch.setenv("PHOENIX_API_KEY", "px-key") - cfg = phoenix_preset() - assert [e for e in cfg.exporters if e.owner == ExporterOwner.ARIZE_PHOENIX] != [] + phoenix_exporters = [e for e in cfg.exporters if e.owner == ExporterOwner.ARIZE_PHOENIX] + assert len(phoenix_exporters) == 1 + assert phoenix_exporters[0].endpoint == "http://localhost:6006/v1/traces" def test_agentops_exporter_factory_is_registered(): diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 6e9fa875a24..641c552d144 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -3981,15 +3981,14 @@ def test_failure_handler_zeroes_spend_without_recovered_usage(logging_obj): assert payload["total_tokens"] == 0 -def test_admin_owned_destination_uses_otel_v2_without_global_flag(monkeypatch): - # An admin-owned logging destination (a "logging" credential, created from the - # UI) must make the backend resolve to the OTEL v2 logger even when - # LITELLM_OTEL_V2 is off, so the per-destination credentials drive the export. - # Otherwise activation falls back to the legacy global logger, which ignores - # the destination's credentials and exports with absent global env creds. +def test_admin_owned_destination_does_not_activate_v2_without_flag(monkeypatch): + # LITELLM_OTEL_V2 is the sole activation gate: registering an admin-owned logging + # destination must NOT flip a v1 deployment onto v2. With the flag off, + # _maybe_construct_otel_v2 returns None whether or not a destination exists, so an + # existing v1 deployment is unaffected by merely registering a credential (and the + # flag-off + destination "orphaned tree" configuration can't arise). from types import SimpleNamespace - from litellm.integrations.otel.logger import OpenTelemetryV2 from litellm.integrations.otel.model.config import is_otel_v2_enabled from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2 @@ -4001,7 +4000,7 @@ def test_admin_owned_destination_uses_otel_v2_without_global_flag(monkeypatch): monkeypatch.setattr(litellm, "credential_list", []) assert _maybe_construct_otel_v2("arize", []) is None - # A logging destination registered for the backend -> v2 owns it. + # A logging destination registered for the backend, flag still off -> still None. monkeypatch.setattr( litellm, "credential_list", @@ -4015,10 +4014,9 @@ def test_admin_owned_destination_uses_otel_v2_without_global_flag(monkeypatch): ) ], ) - logger = _maybe_construct_otel_v2("arize", []) + result = _maybe_construct_otel_v2("arize", []) is_otel_v2_enabled.cache_clear() - assert isinstance(logger, OpenTelemetryV2) - assert logger.callback_name == "arize" + assert result is None def test_credential_mandatory_backend_global_misconfig_stays_loud(monkeypatch): @@ -4065,36 +4063,33 @@ def test_credential_mandatory_backend_global_misconfig_stays_loud(monkeypatch): assert logger.callback_name == "weave_otel" -def test_generic_admin_destination_builds_otel_v2_logger(monkeypatch): - # The Generic OTLP passthrough ('generic') must build an OpenTelemetryV2 logger when - # an admin-owned generic destination is registered (even with LITELLM_OTEL_V2 off), - # so the gen-AI span routes to the destination's otel_endpoint. Without a generic - # destination and without the global flag, it stays None (no generic logger). +def test_generic_admin_destination_needs_flag_to_build_otel_v2_logger(monkeypatch): + # The Generic OTLP passthrough ('generic') builds an OpenTelemetryV2 logger only + # when LITELLM_OTEL_V2 is on. Registering an admin-owned generic destination with + # the flag off must NOT construct a v2 logger (the flag is the sole activation + # gate); with the flag on it does. from types import SimpleNamespace from litellm.integrations.otel.logger import OpenTelemetryV2 from litellm.integrations.otel.model.config import is_otel_v2_enabled from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2 + generic_dest = [ + SimpleNamespace( + credential_name="ui-generic", + credential_info={"credential_type": "logging", "description": "generic"}, + ) + ] + + # Flag off + destination registered -> still None (no v2, no flip onto v2). monkeypatch.delenv("LITELLM_OTEL_V2", raising=False) is_otel_v2_enabled.cache_clear() - - monkeypatch.setattr(litellm, "credential_list", []) + monkeypatch.setattr(litellm, "credential_list", generic_dest) assert _maybe_construct_otel_v2("generic", []) is None - monkeypatch.setattr( - litellm, - "credential_list", - [ - SimpleNamespace( - credential_name="ui-generic", - credential_info={ - "credential_type": "logging", - "description": "generic", - }, - ) - ], - ) + # Flag on + destination registered -> builds the v2 generic logger. + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + is_otel_v2_enabled.cache_clear() logger = _maybe_construct_otel_v2("generic", []) is_otel_v2_enabled.cache_clear() assert isinstance(logger, OpenTelemetryV2) diff --git a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py index fe0edeb47ce..2359876f1bb 100644 --- a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py @@ -122,6 +122,37 @@ def test_update_db_credential_replaces_info_for_provider_credential(): assert merged.credential_info == {"custom_llm_provider": "azure"} +def test_sync_in_memory_credential_merges_provider_info(monkeypatch): + """The in-memory mirror merges credential_info for every credential (pre-PR + parity). A partial provider PATCH (e.g. only description) must not drop + custom_llm_provider from litellm.credential_list; a wholesale replace made the + credential unresolvable (401) until the next scheduled DB reload.""" + from litellm.proxy.credential_endpoints.endpoints import _sync_in_memory_credential + from litellm.types.utils import UpdateCredentialItem + + existing = CredentialItem( + credential_name="openai-prod", + credential_values={"api_key": "enc"}, + credential_info={"custom_llm_provider": "openai", "keepme": "important"}, + ) + monkeypatch.setattr(litellm, "credential_list", [existing]) + patch = UpdateCredentialItem(credential_info={"description": "just a label"}) + merged = CredentialItem( + credential_name="openai-prod", + credential_values={"api_key": "enc"}, + credential_info={"description": "just a label"}, + ) + + _sync_in_memory_credential(old_name="openai-prod", merged=merged, patch=patch) + + in_memory = next(c for c in litellm.credential_list if c.credential_name == "openai-prod") + assert in_memory.credential_info == { + "custom_llm_provider": "openai", + "keepme": "important", + "description": "just a label", + } + + # --- access-shape validation is scoped to logging destinations --------------- @pytest.mark.asyncio 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 9060e5a7443..91d4de27b8c 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -4946,10 +4946,22 @@ def _seeded_logging_credentials(): credential_info={"custom_llm_provider": "openai"}, ), ] + # Admin-owned destinations are gated on LITELLM_OTEL_V2; the resolver no-ops when + # the flag is off, so exercise these tests with the feature actually enabled. + from litellm.integrations.otel.model.config import is_otel_v2_enabled + + prev_flag = os.environ.get("LITELLM_OTEL_V2") + os.environ["LITELLM_OTEL_V2"] = "true" + is_otel_v2_enabled.cache_clear() try: yield finally: litellm.credential_list = original + if prev_flag is None: + os.environ.pop("LITELLM_OTEL_V2", None) + else: + os.environ["LITELLM_OTEL_V2"] = prev_flag + is_otel_v2_enabled.cache_clear() def _auth(token="hashed-key", org_id=None, team_id="team-x"): @@ -5661,8 +5673,12 @@ async def test_resolve_logging_exporters_short_circuits_without_destinations(mon async def test_resolve_logging_exporters_runs_lookup_when_a_destination_exists(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.""" + 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", @@ -5687,3 +5703,39 @@ async def test_resolve_logging_exporters_runs_lookup_when_a_destination_exists(m assert lookups["org"] == 1 # a destination exists, so the resolver runs the lookup assert "generic" in backends # global access grants the team key + + +@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 + returns nothing even when a global destination is registered, so no backend is + activated for the request and an existing v1 deployment is unaffected.""" + from litellm.integrations.otel.model.config import is_otel_v2_enabled + from litellm.proxy import litellm_pre_call_utils as pcu + + monkeypatch.delenv("LITELLM_OTEL_V2", raising=False) + is_otel_v2_enabled.cache_clear() + + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="d-global", + credential_values={"otel_endpoint": "https://collector/v1/traces"}, + credential_info={"credential_type": "logging", "description": "generic", "access": {"global": True}}, + ) + ], + ) + + async def _boom_org(user_api_key_dict): + raise AssertionError("resolver must short-circuit before any DB lookup when the flag is off") + + monkeypatch.setattr(pcu, "_effective_org_id", _boom_org) + + key = UserAPIKeyAuth(api_key="k", team_id="t1") + destinations, backends = await pcu._resolve_logging_exporters(key) + is_otel_v2_enabled.cache_clear() + + assert destinations == () + assert backends == ()