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.
This commit is contained in:
Yucheng Zhu 2026-07-30 16:10:15 -07:00
parent 22b04d0f67
commit f4fd544c5c
10 changed files with 177 additions and 122 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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