mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
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:
parent
22b04d0f67
commit
f4fd544c5c
10 changed files with 177 additions and 122 deletions
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == ()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue