diff --git a/.gitignore b/.gitignore index fda3311fe02..651e1c6a979 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,7 @@ crash.*.log # and should be committed. .vscode .pin_list.txt +repro_lit3850/ + +# throwaway learning scripts (not a feature) +scratch/ diff --git a/litellm/constants.py b/litellm/constants.py index c0e265c0e4a..8324b4b27c9 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -512,6 +512,11 @@ DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE = os.getenv( LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED = 499 +# Reserved key/team logging callback var that binds the callback to a named OTEL +# credential in the registry (an admin-owned reference resolved server-side into a +# trace destination, never forwarded as a request parameter). +LITELLM_LOGGING_CREDENTIAL_NAME_KEY = "litellm_logging_credential_name" + EMAIL_BUDGET_ALERT_TTL = int( os.getenv("EMAIL_BUDGET_ALERT_TTL", 24 * 60 * 60) ) # 24 hours in seconds diff --git a/litellm/integrations/otel/README.md b/litellm/integrations/otel/README.md index 17011bb8db7..fb584ccdb15 100644 --- a/litellm/integrations/otel/README.md +++ b/litellm/integrations/otel/README.md @@ -247,12 +247,13 @@ lives in [`plumbing/`](./plumbing): - [`presets/`](./presets) — each preset reads one integration's env vars and returns an `OpenTelemetryV2Config` (exporter destination + mapper vocabularies + resource attributes). `PRESET_BY_CALLBACK` maps a callback name (`"arize"`, - `"langfuse_otel"`, …) to its preset. Integrations that support team/key-scoped - credentials also provide a per-request OTLP header builder - (`DYNAMIC_HEADERS_BY_CALLBACK`). Presets do **no** network I/O at build time: - AgentOps, for example, mints its JWT lazily inside a custom exporter on the - first export (in the `BatchSpanProcessor` worker thread), never on the event - loop. + `"langfuse_otel"`, …) to its preset. Per-key/team routing is **not** here: a + destination is admin-owned infrastructure, resolved server-side from a named + credential into an `OtelDestination` (`destinations.py`) and applied by + `plumbing/routing.py`. Nothing in `presets/` reads vendor credentials or a host + off a request. Presets do **no** network I/O at build time: AgentOps, for + example, mints its JWT lazily inside a custom exporter on the first export (in + the `BatchSpanProcessor` worker thread), never on the event loop. ## Extending @@ -261,7 +262,8 @@ lives in [`plumbing/`](./plumbing): `key -> extractor` tables) and register it in `mappers/__init__._MAPPER_BY_NAME`. - **A new integration**: add a preset in `presets/` that returns an `OpenTelemetryV2Config`, and register it in `presets/__init__.PRESET_BY_CALLBACK`. - If it supports dynamic credentials, add a header builder to - `DYNAMIC_HEADERS_BY_CALLBACK`. + For admin-owned per-key/team destinations, add an adapter mapping the named + credential's values to an `OtelDestination` in `destinations._ADAPTERS` (or rely + on the generic `otel_endpoint`/`otel_headers` passthrough). - **A new span kind**: add a role to `spans.py` (registry entry + name builder), a payload dataclass in `payloads.py`, and a branch in the relevant mapper(s). diff --git a/litellm/integrations/otel/destinations.py b/litellm/integrations/otel/destinations.py new file mode 100644 index 00000000000..af1270b7232 --- /dev/null +++ b/litellm/integrations/otel/destinations.py @@ -0,0 +1,106 @@ +"""Resolve an admin-owned named credential into a typed OTLP destination. + +The destination (endpoint + auth headers) is admin infrastructure config. Each +OTEL backend stores its own fields on the named credential's free-form +``credential_values``; the adapter here maps those fields to the universal +``OtelDestination`` the v2 router exports through. A backend with no bespoke +adapter is still reachable through the generic ``otel_endpoint`` / ``otel_headers`` +passthrough, so the registry covers every OTEL destination rather than an +enumerated few. Nothing here reads request data; callers pass admin-resolved +credential values only. +""" + +from typing import Callable, Mapping, Optional + +from litellm.constants import LITELLM_LOGGING_CREDENTIAL_NAME_KEY +from litellm.integrations.langfuse.langfuse_otel import ( + LANGFUSE_CLOUD_US_ENDPOINT, + LangfuseOtelLogger, +) +from litellm.integrations.otel.model.destination import OtelDestination +from litellm.integrations.weave.weave_otel import _get_weave_authorization_header + +#: Reserved ``callback_vars`` key binding a key/team's logging callback to a named +#: credential in the registry. It is a reference, resolved server-side; it is never +#: forwarded as a request parameter. +LOGGING_CREDENTIAL_NAME_KEY = LITELLM_LOGGING_CREDENTIAL_NAME_KEY + + +def _parse_header_string(raw: str) -> dict[str, str]: + pairs = (item.split("=", 1) for item in raw.split(",") if "=" in item) + return {key.strip(): value.strip() for key, value in pairs} + + +def _langfuse_endpoint(host: str) -> str: + normalized = host if host.startswith("http") else f"https://{host}" + return f"{normalized.rstrip('/')}/api/public/otel" + + +def _langfuse_destination(values: Mapping[str, str]) -> Optional[OtelDestination]: + public_key = values.get("langfuse_public_key") + secret_key = values.get("langfuse_secret_key") + if not public_key or not secret_key: + return None + host = values.get("langfuse_host") + endpoint = _langfuse_endpoint(host) if host else LANGFUSE_CLOUD_US_ENDPOINT + auth = LangfuseOtelLogger._get_langfuse_authorization_header( + public_key=public_key, secret_key=secret_key + ) + return OtelDestination(endpoint=endpoint, headers={"Authorization": auth}) + + +def _arize_destination(values: Mapping[str, str]) -> Optional[OtelDestination]: + space = values.get("arize_space_id") or values.get("arize_space_key") + api_key = values.get("arize_api_key") + if not space or not api_key: + return None + endpoint = values.get("arize_endpoint") or "https://otlp.arize.com/v1" + return OtelDestination( + endpoint=endpoint, headers={"space_id": space, "api_key": api_key} + ) + + +def _weave_destination(values: Mapping[str, str]) -> Optional[OtelDestination]: + api_key = values.get("wandb_api_key") + endpoint = values.get("weave_endpoint") + if not api_key or not endpoint: + return None + headers = {"Authorization": _get_weave_authorization_header(api_key=api_key)} + project_id = values.get("weave_project_id") + if project_id: + headers["project_id"] = project_id + return OtelDestination(endpoint=endpoint, headers=headers) + + +def _generic_destination(values: Mapping[str, str]) -> Optional[OtelDestination]: + """Any OTLP backend: an explicit endpoint plus raw headers. The catch-all that + makes the registry cover self-hosted collectors / Phoenix / Honeycomb / etc.""" + endpoint = values.get("otel_endpoint") + if not endpoint: + return None + return OtelDestination( + endpoint=endpoint, headers=_parse_header_string(values.get("otel_headers", "")) + ) + + +_ADAPTERS: dict[str, Callable[[Mapping[str, str]], Optional[OtelDestination]]] = { + "langfuse_otel": _langfuse_destination, + "arize": _arize_destination, + "weave_otel": _weave_destination, +} + +#: OTEL v2 callbacks that can be routed to a per-key/team admin destination. +OTEL_V2_DESTINATION_CALLBACKS = frozenset(_ADAPTERS) + + +def build_destination( + callback_name: str, values: Mapping[str, str] +) -> Optional[OtelDestination]: + """Map an admin credential's ``values`` to an ``OtelDestination`` for + ``callback_name``, falling back to the generic OTLP passthrough.""" + adapter = _ADAPTERS.get(callback_name) + if adapter is not None: + destination = adapter(values) + if destination is not None: + return destination + return _generic_destination(values) diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 79931c0796c..b6e0869d075 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -167,6 +167,17 @@ class OpenTelemetryV2(CustomLogger): if getattr(proxy_server, "open_telemetry_logger", None) is None: setattr(proxy_server, "open_telemetry_logger", self) + def _destinations_for_backend(self, call: "LLMCallEvent") -> tuple: + """The call's admin-resolved destinations that belong to THIS logger's backend. + + A request fans out across whatever exporters its identity chain is assigned; + each logger exports only the destinations tagged with its own callback_name, + so each backend's span keeps its own attribute vocabulary. + """ + return tuple( + d for d in call.otel_destinations if d.callback_name == self.callback_name + ) + # ====================================================================== # # LLM-call callbacks — the span is opened at the ``pre_call`` boundary and # closed here. See ``log_pre_api_call``. @@ -215,7 +226,7 @@ class OpenTelemetryV2(CustomLogger): parent_context=parent_context, start_time_ns=start_time_ns, tracer=self._tenant_tracers.tracer_for( - self.tracer, call.dynamic_params + self.tracer, self._destinations_for_backend(call) ), ) self._open_llm_calls[call_id] = _LLMCallSpan( @@ -353,7 +364,9 @@ class OpenTelemetryV2(CustomLogger): parent_context=parent_ctx, start_time_ns=carrier.start_time_ns, end_time_ns=end_time_ns, - tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params), + tracer=self._tenant_tracers.tracer_for( + self.tracer, self._destinations_for_backend(call) + ), ) # ====================================================================== # diff --git a/litellm/integrations/otel/model/destination.py b/litellm/integrations/otel/model/destination.py new file mode 100644 index 00000000000..9732baf3b6c --- /dev/null +++ b/litellm/integrations/otel/model/destination.py @@ -0,0 +1,26 @@ +"""The resolved, admin-owned OTLP destination. + +A trace destination is admin-owned infrastructure config, never request data. +The proxy resolves a key/team's bound named credential into this typed, +backend-agnostic target (an endpoint plus auth headers) server-side, and the v2 +logger exports through it. Every OTEL backend -- Langfuse, Arize, Weave, a +self-hosted collector -- reduces to this shape; the per-backend field mapping +lives in ``litellm.integrations.otel.destinations``. +""" + +from pydantic import BaseModel, ConfigDict, Field + + +class OtelDestination(BaseModel): + model_config = ConfigDict(frozen=True) + + endpoint: str + headers: dict[str, str] = Field(default_factory=dict) + # The OTEL backend (callback_name) this destination belongs to, so a request + # that fans out across backends routes each destination to the logger that + # owns its attribute vocabulary. None for the legacy single-destination path. + callback_name: str | None = None + + def header_string(self) -> str: + """Render headers as the ``k=v,k2=v2`` form an ``ExporterSpec`` expects.""" + return ",".join(f"{key}={value}" for key, value in self.headers.items()) diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index 4c9cecfef57..c8b28a73175 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -39,7 +39,10 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Mapping, cast +from pydantic import ValidationError + from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL +from litellm.integrations.otel.model.destination import OtelDestination from litellm.integrations.otel.model.semconv import resolve_operation from litellm.integrations.otel.model.utils import as_str @@ -47,6 +50,31 @@ if TYPE_CHECKING: from litellm.types.utils import StandardLoggingPayload +def _otel_destinations(dynamic_params: Any) -> tuple[OtelDestination, ...]: + """The admin-resolved OTLP destinations carried on ``standard_callback_dynamic_params``. + + Server-set only (the proxy resolves the exporters assigned to the request's + identity chain and strips any client value), so this is the sole source the v2 + router trusts -- request-supplied vendor credentials are never read here. A + request fans out to every destination here; each logger keeps only the ones + tagged with its own backend. + """ + if not isinstance(dynamic_params, Mapping): + return () + raw = dynamic_params.get("otel_destinations") + if not isinstance(raw, list): + return () + parsed: list[OtelDestination] = [] + for item in raw: + if not isinstance(item, Mapping): + continue + try: + parsed.append(OtelDestination.model_validate(dict(item))) + except ValidationError: + continue + return tuple(parsed) + + @dataclass(frozen=True) class RequestIdentity: call_id: str | None = None @@ -210,6 +238,10 @@ class LLMCallEvent: # The ``standard_callback_dynamic_params`` routing the call to a per-tenant # tracer (its own exporter/endpoint), or ``None`` when the call isn't scoped. dynamic_params: Any + # The admin-resolved OTLP destinations (endpoint + auth headers) for this call's + # identity chain, fanned out to. Empty when none are assigned. The only source the + # v2 router trusts for per-tenant routing; never request-derived. + otel_destinations: tuple[OtelDestination, ...] # True for synthetic proxy-gate logs (auth / rate-limit rejections): they fire # the ``pre_call`` hook but never made an upstream call, so they get no span. is_no_upstream_call: bool @@ -224,10 +256,12 @@ class LLMCallEvent: payload = cast("StandardLoggingPayload", raw_payload) if raw_payload else None operation = resolve_operation(as_str(kwargs.get("call_type"))) model = as_str(kwargs.get("model")) or "" + dynamic_params = kwargs.get("standard_callback_dynamic_params") return cls( call_id=_call_id(payload, kwargs), payload=payload, - dynamic_params=kwargs.get("standard_callback_dynamic_params"), + dynamic_params=dynamic_params, + otel_destinations=_otel_destinations(dynamic_params), is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)), provisional_span_name=f"{operation.value} {model}".strip(), ) diff --git a/litellm/integrations/otel/plumbing/routing.py b/litellm/integrations/otel/plumbing/routing.py index 1f2f1b202d9..5c5a245e450 100644 --- a/litellm/integrations/otel/plumbing/routing.py +++ b/litellm/integrations/otel/plumbing/routing.py @@ -1,36 +1,37 @@ -"""Per-request multi-tenant tracer routing. +"""Per-request multi-tenant tracer routing with fan-out. -When a request carries team/key vendor credentials in -``standard_callback_dynamic_params``, its spans must export through a -``TracerProvider`` whose OTLP headers carry those credentials. -``TenantTracerCache`` builds and caches one provider per distinct credential -set, and otherwise hands back the logger's default tracer. This lets a single -logger fan requests out to many tenants without needing a logger per tenant. +A call's identity chain is assigned a set of admin-owned OTEL destinations +(``LLMCallEvent.otel_destinations``, resolved server-side from named credentials). +Its spans must export to ALL of them plus the configured/global exporter, so +``TenantTracerCache`` builds and caches one ``TracerProvider`` per distinct +destination SET -- the provider keeps the configured exporters and appends one +``SpanProcessor`` per destination, so a span is emitted once and copied to each +(no duplicate spans). With no destinations it hands back the logger's default +tracer (global only). Destinations are never request-derived, so a caller can +neither redirect a trace nor spawn providers. """ from collections import OrderedDict -from typing import Any, Mapping from opentelemetry.sdk.trace import TracerProvider from opentelemetry.trace import Tracer from litellm._logging import verbose_logger -from litellm.integrations.otel.model.config import OpenTelemetryV2Config -from litellm.integrations.otel.presets import dynamic_otlp_headers +from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config +from litellm.integrations.otel.model.destination import OtelDestination from litellm.integrations.otel.plumbing.providers import ( build_tracer_provider, get_tracer, ) -# Exporter kinds that ignore headers — never rewritten with dynamic credentials. +# Exporter kinds that ignore endpoint/headers — never rewritten with a destination. _NON_OTLP_KINDS = ("console", "in_memory", "inmemory", "memory") -# Cap on distinct credential-scoped providers held at once. ``dynamic_params`` -# can be populated from request metadata, so an unbounded cache lets a caller -# spawn one ``TracerProvider`` (plus its ``BatchSpanProcessor`` background -# thread) per unique credential set and exhaust the proxy. The LRU bound keeps -# the working set of active tenants resident while flushing and shutting down -# evicted providers so their threads are reclaimed. +# Cap on distinct destination-scoped providers held at once. Destinations are +# admin-owned (one per key/team), so this is resource hygiene rather than an +# anti-abuse bound: it keeps the working set of active tenants resident while +# flushing and shutting down evicted providers so their exporter threads are +# reclaimed. _MAX_CACHED_PROVIDERS = 256 @@ -49,7 +50,7 @@ def _shutdown_provider(provider: TracerProvider) -> None: class TenantTracerCache: - """Credential-scoped ``TracerProvider`` cache keyed by the dynamic headers.""" + """Destination-scoped ``TracerProvider`` cache keyed by endpoint + headers.""" def __init__( self, @@ -60,52 +61,68 @@ class TenantTracerCache: self._config = config self._callback_name = callback_name self._tracer_name = tracer_name - self._providers: "OrderedDict[tuple[tuple[str, str], ...], TracerProvider]" = ( - OrderedDict() - ) + self._providers: "OrderedDict[tuple[tuple[str, tuple[tuple[str, str], ...]], ...], TracerProvider]" = (OrderedDict()) - def tracer_for(self, default: Tracer, dynamic_params: Any) -> Tracer: + def tracer_for( + self, default: Tracer, destinations: "tuple[OtelDestination, ...]" + ) -> Tracer: """Return the tracer for this request. - Use ``default`` unless the request's dynamic credentials require a - credential-scoped tracer, in which case build (or reuse) one. The cache - is a bounded LRU: the least-recently-used provider is flushed and shut - down on overflow so its exporter threads don't accumulate. + ``destinations`` are the admin-resolved exporters (for this backend) that the + request's identity chain is assigned. Empty -> the logger's default tracer + (the global/configured exporter only = deny). Otherwise build (or reuse) a + provider that exports to the configured exporters PLUS every destination, so + one span is emitted once and copied to all (fan-out, no duplicate spans). The + cache is a bounded LRU keyed on the destination SET. """ - headers = dynamic_otlp_headers(self._callback_name, dynamic_params) - if not headers: + if not destinations: return default - cache_key = tuple(sorted(headers.items())) + cache_key = tuple( + sorted((d.endpoint, tuple(sorted(d.headers.items()))) for d in destinations) + ) provider = self._providers.get(cache_key) if provider is not None: self._providers.move_to_end(cache_key) else: - provider = build_tracer_provider(self._config_with_headers(headers)) + provider = build_tracer_provider( + self._config_with_destinations(destinations) + ) self._providers[cache_key] = provider if len(self._providers) > _MAX_CACHED_PROVIDERS: _, evicted = self._providers.popitem(last=False) _shutdown_provider(evicted) return get_tracer(provider, self._tracer_name) - def _config_with_headers(self, headers: Mapping[str, str]) -> OpenTelemetryV2Config: - """Clone the config, stamping ``headers`` onto the credential's own exporter. - - ``headers`` are the per-request credentials of ``self._callback_name`` (the - integration that built this cache), so they apply only to the exporter that - integration contributed (``spec.owner``). A request that carries one - tenant's Arize key must never rewrite the headers of a co-configured - Langfuse or self-hosted collector exporter, which would leak that key to a - different backend. - """ - header_str = ",".join(f"{key}={value}" for key, value in headers.items()) - header_update: dict[str, str] = {"headers": header_str} - exporters = [ - ( - spec.model_copy(update=header_update) - if spec.owner == self._callback_name + def _owned_otlp_kind(self) -> str: + """The OTLP transport of this integration's own exporter (langfuse -> http, + arize -> grpc), used for the destinations appended below.""" + for spec in self._config.exporters: + if ( + spec.owner == self._callback_name and spec.kind.lower() not in _NON_OTLP_KINDS - else spec + ): + return spec.kind + return "otlp_http" + + def _config_with_destinations( + self, destinations: "tuple[OtelDestination, ...]" + ) -> OpenTelemetryV2Config: + """Clone the config, KEEPING its exporters (so the global/default still + receives) and APPENDING one exporter per resolved destination. The shared + ``TracerProvider`` attaches one ``SpanProcessor`` per spec, so a single span + is emitted once and exported to the global destination plus every assigned + one. Each appended exporter's endpoint is the resolved host (the cross-host + fix) with its own auth headers (per-destination isolation).""" + kind = self._owned_otlp_kind() + appended = [ + ExporterSpec( + kind=kind, + endpoint=d.endpoint, + headers=d.header_string(), + owner=None, ) - for spec in self._config.exporters + for d in destinations ] - return self._config.model_copy(update={"exporters": exporters}) + return self._config.model_copy( + update={"exporters": [*self._config.exporters, *appended]} + ) diff --git a/litellm/integrations/otel/presets/__init__.py b/litellm/integrations/otel/presets/__init__.py index c69d257ab52..df9ffc3f7d9 100644 --- a/litellm/integrations/otel/presets/__init__.py +++ b/litellm/integrations/otel/presets/__init__.py @@ -6,22 +6,22 @@ vocabularies to apply, and any resource attributes. ``PRESET_BY_CALLBACK`` maps a callback name (``"arize"``, ``"langfuse_otel"``, ...) to its preset so the factory in ``litellm_logging`` can resolve a name and build a single ``OpenTelemetryV2`` instance from the result. + +Per-key/team routing does not live here. A trace destination is admin-owned +infrastructure config, resolved server-side from a named credential into an +``OtelDestination`` (see ``litellm.integrations.otel.destinations`` and +``plumbing.routing``); nothing in this package reads vendor credentials or a +host off a request. """ -from typing import Callable - from litellm.integrations.otel.presets.agentops import agentops_preset -from litellm.integrations.otel.presets.arize import arize_dynamic_headers, arize_preset +from litellm.integrations.otel.presets.arize import arize_preset from litellm.integrations.otel.presets.base import Preset -from litellm.integrations.otel.presets.langfuse import ( - langfuse_dynamic_headers, - langfuse_preset, -) +from litellm.integrations.otel.presets.langfuse import langfuse_preset from litellm.integrations.otel.presets.langtrace import langtrace_preset from litellm.integrations.otel.presets.levo import levo_preset from litellm.integrations.otel.presets.phoenix import phoenix_preset -from litellm.integrations.otel.presets.weave import weave_dynamic_headers, weave_preset -from litellm.types.utils import StandardCallbackDynamicParams +from litellm.integrations.otel.presets.weave import weave_preset #: Callback name → preset. The ``Preset`` annotation makes mypy verify every #: registered value matches the preset interface. @@ -35,39 +35,10 @@ PRESET_BY_CALLBACK: dict[str, Preset] = { "weave_otel": weave_preset, } -#: Callback name → per-request OTLP header builder (team/key multi-tenant -#: routing). Only integrations that support dynamic credentials appear here — -#: Arize-Phoenix/Langtrace/Levo/AgentOps don't, so they use the logger's -#: default tracer. -DYNAMIC_HEADERS_BY_CALLBACK: dict[ - str, Callable[[StandardCallbackDynamicParams], dict[str, str]] -] = { - "arize": arize_dynamic_headers, - "langfuse_otel": langfuse_dynamic_headers, - "weave_otel": weave_dynamic_headers, -} - - -def dynamic_otlp_headers( - callback_name: str | None, - dynamic_params: StandardCallbackDynamicParams | None, -) -> dict[str, str] | None: - """Per-request OTLP headers for ``callback_name``, or ``None`` if N/A. - - ``None`` means "no per-request routing" — the caller uses its default tracer. - """ - builder = DYNAMIC_HEADERS_BY_CALLBACK.get(callback_name or "") - if builder is None or not dynamic_params: - return None - headers = builder(dynamic_params) - return headers or None - __all__ = [ "PRESET_BY_CALLBACK", - "DYNAMIC_HEADERS_BY_CALLBACK", "Preset", - "dynamic_otlp_headers", "agentops_preset", "arize_preset", "langfuse_preset", diff --git a/litellm/integrations/otel/presets/arize.py b/litellm/integrations/otel/presets/arize.py index b6af88c6b34..3dd8b226cde 100644 --- a/litellm/integrations/otel/presets/arize.py +++ b/litellm/integrations/otel/presets/arize.py @@ -10,7 +10,6 @@ from litellm.integrations.otel.model.config import ( OpenTelemetryV2Config, ) from litellm.integrations.otel.presets.utils import ensure_mappers -from litellm.types.utils import StandardCallbackDynamicParams class _ArizeSettings(BaseSettings): @@ -65,16 +64,3 @@ def _arize_headers(arize_cfg) -> str | None: # credentials are configured. return _ArizeSettings().otlp_traces_headers return ",".join(pieces) - - -def arize_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]: - """Per-request Arize OTLP headers from team/key dynamic params.""" - headers: dict[str, str] = {} - # ``arize_space_key`` is the suggested param and wins over ``arize_space_id``. - space = params.get("arize_space_key") or params.get("arize_space_id") - if space: - headers["arize-space-id"] = space - api_key = params.get("arize_api_key") - if api_key: - headers["api_key"] = api_key - return headers diff --git a/litellm/integrations/otel/presets/langfuse.py b/litellm/integrations/otel/presets/langfuse.py index 5631da6429f..9f62a01a17a 100644 --- a/litellm/integrations/otel/presets/langfuse.py +++ b/litellm/integrations/otel/presets/langfuse.py @@ -9,7 +9,6 @@ from litellm.integrations.otel.model.config import ( OpenTelemetryV2Config, ) from litellm.integrations.otel.presets.utils import ensure_mappers -from litellm.types.utils import StandardCallbackDynamicParams def langfuse_preset( @@ -33,16 +32,3 @@ def langfuse_preset( "mapper_names": ensure_mappers(base.mapper_names, "langfuse"), } ) - - -def langfuse_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]: - """Per-request Langfuse OTLP headers from team/key dynamic params.""" - public_key = params.get("langfuse_public_key") - secret_key = params.get("langfuse_secret_key") - if public_key and secret_key: - return { - "Authorization": _V1Langfuse._get_langfuse_authorization_header( - public_key=public_key, secret_key=secret_key - ) - } - return {} diff --git a/litellm/integrations/otel/presets/weave.py b/litellm/integrations/otel/presets/weave.py index d22f7641289..1f39eaf38ec 100644 --- a/litellm/integrations/otel/presets/weave.py +++ b/litellm/integrations/otel/presets/weave.py @@ -6,11 +6,7 @@ from litellm.integrations.otel.model.config import ( OpenTelemetryV2Config, ) from litellm.integrations.otel.presets.utils import ensure_mappers -from litellm.integrations.weave.weave_otel import ( - _get_weave_authorization_header, - get_weave_otel_config, -) -from litellm.types.utils import StandardCallbackDynamicParams +from litellm.integrations.weave.weave_otel import get_weave_otel_config def weave_preset( @@ -34,15 +30,3 @@ def weave_preset( "mapper_names": ensure_mappers(base.mapper_names, "openinference", "weave"), } ) - - -def weave_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]: - """Per-request Weave OTLP headers from team/key dynamic params.""" - headers: dict[str, str] = {} - api_key = params.get("wandb_api_key") - if api_key: - headers["Authorization"] = _get_weave_authorization_header(api_key=api_key) - project_id = params.get("weave_project_id") - if project_id: - headers["project_id"] = project_id - return headers diff --git a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py index 949076aabf3..cab8de699dd 100644 --- a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py +++ b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py @@ -1,6 +1,6 @@ -from typing import Dict, Optional +from typing import Dict, Optional, cast -from litellm.types.utils import StandardCallbackDynamicParams +from litellm.types.utils import OtelDestinationParams, StandardCallbackDynamicParams def _is_env_reference(value: object) -> bool: @@ -108,4 +108,15 @@ def initialize_standard_callback_dynamic_params( ) standard_callback_dynamic_params[param] = _param_value # type: ignore + # Admin-owned OTEL v2 destinations, resolved server-side by the proxy from the + # exporters assigned to the request's identity chain and stamped onto top-level + # kwargs (the proxy strips any client-supplied value first). Read from top-level + # only, never from request metadata, and never via _supported_callback_params, + # so a request body/metadata cannot set or select a trace destination. + otel_destinations = kwargs.get("otel_destinations") + if isinstance(otel_destinations, list): + standard_callback_dynamic_params["otel_destinations"] = cast( + "list[OtelDestinationParams]", otel_destinations + ) + return standard_callback_dynamic_params diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e856e5e3cdb..7e33ef12d7b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -16,7 +16,10 @@ from pydantic import ( from typing_extensions import Required, TypedDict from litellm._uuid import uuid -from litellm.constants import MCP_STDIO_ALLOWED_COMMANDS +from litellm.constants import ( + LITELLM_LOGGING_CREDENTIAL_NAME_KEY, + MCP_STDIO_ALLOWED_COMMANDS, +) from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( validate_no_callback_env_reference, ) @@ -1883,7 +1886,9 @@ class AddTeamCallback(LiteLLMPydanticObjectBase): @classmethod def validate_callback_vars(cls, values): callback_vars = values.get("callback_vars", {}) - valid_keys = set(StandardCallbackDynamicParams.__annotations__.keys()) + valid_keys = set(StandardCallbackDynamicParams.__annotations__.keys()) | { + LITELLM_LOGGING_CREDENTIAL_NAME_KEY + } for key, value in callback_vars.items(): if key not in valid_keys: raise ValueError( @@ -1926,7 +1931,9 @@ class TeamCallbackMetadata(LiteLLMPydanticObjectBase): "callbacks": [], "callback_vars": {}, } - valid_keys = set(StandardCallbackDynamicParams.__annotations__.keys()) + valid_keys = set(StandardCallbackDynamicParams.__annotations__.keys()) | { + LITELLM_LOGGING_CREDENTIAL_NAME_KEY + } if callback_vars is not None: for key in callback_vars: if key not in valid_keys: diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index a716857111b..48fb5ed65fc 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -10,9 +10,13 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.litellm_logging import _get_masked_values -from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth +from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper +from litellm.proxy.management_endpoints.logging_exporter_validation import ( + is_admin_gated_credential_info, + validate_credential_access, +) from litellm.proxy.utils import handle_exception_on_proxy, jsonify_object from litellm.repositories.credentials_repository import CredentialsRepository from litellm.types.utils import CreateCredentialItem, CredentialItem @@ -20,6 +24,25 @@ from litellm.types.utils import CreateCredentialItem, CredentialItem router = APIRouter() +def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None: + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail={"error": "Only the proxy admin can manage logging credentials"}, + ) + + +def _credential_in_memory(credential_name: str) -> Optional[CredentialItem]: + return next( + ( + cred + for cred in litellm.credential_list + if cred.credential_name == credential_name + ), + None, + ) + + class CredentialHelperUtils: @staticmethod def encrypt_credential_values( @@ -59,6 +82,10 @@ async def create_credential( """ from litellm.proxy.proxy_server import llm_router, prisma_client + if is_admin_gated_credential_info(credential.credential_info): + _require_proxy_admin(user_api_key_dict) + validate_credential_access(credential.credential_info) + try: if prisma_client is None: raise HTTPException( @@ -240,6 +267,12 @@ async def delete_credential( """ from litellm.proxy.proxy_server import prisma_client + existing = _credential_in_memory(credential_name) + if existing is not None and is_admin_gated_credential_info( + existing.credential_info + ): + _require_proxy_admin(user_api_key_dict) + try: if prisma_client is None: raise HTTPException( @@ -290,10 +323,12 @@ def update_db_credential( merged_credential.credential_values.update(encrypted_params) - # update model info + # Merge the patch into the existing credential_info so a partial update (e.g. only + # access) preserves credential_type/description/host. The prior guard checked for a + # key literally named "credential_info", which is never present, so it reset the dict + # on every patch and dropped the logging tag. if encrypted_credential.credential_info: - """Update credential info""" - if "credential_info" not in merged_credential.credential_info: + if merged_credential.credential_info is None: merged_credential.credential_info = {} merged_credential.credential_info.update(encrypted_credential.credential_info) @@ -319,6 +354,14 @@ async def update_credential( """ from litellm.proxy.proxy_server import prisma_client + existing = _credential_in_memory(credential_name) + if is_admin_gated_credential_info(credential.credential_info) or ( + existing is not None + and is_admin_gated_credential_info(existing.credential_info) + ): + _require_proxy_admin(user_api_key_dict) + validate_credential_access(credential.credential_info) + try: if prisma_client is None: raise HTTPException( diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index c0cdf84dfb6..a4931133404 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -83,6 +83,8 @@ _ENABLE_TEAM_STALE_ALIAS_BYPASS: Optional[bool] = 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 @@ -538,6 +540,161 @@ class KeyAndTeamLoggingSettings: return None +async def _union_logging_exporter_names(user_api_key_dict: UserAPIKeyAuth) -> set: + """The union of admin-assigned exporter names across the request's identity chain. + + Resolves each level from its OWN record: the key's ``metadata`` is shadowed by the + team's on the auth object, so it is fetched fresh via ``get_key_object``; the org's + metadata is fetched via ``get_org_object``; the team's is already its own on + ``team_metadata``. Internal-user is intentionally not a routing dimension. The lists + are admin-owned; the request never supplies them. Degrades to team-only when no DB + is connected (SDK mode). + """ + from litellm.proxy import proxy_server + from litellm.proxy.auth.auth_checks import get_key_object, get_org_object + + names: set = set() + + def _add(metadata: Any) -> None: + if not isinstance(metadata, dict): + return + assigned = metadata.get("logging_exporters") + if isinstance(assigned, list): + names.update(str(name) for name in assigned) + + prisma_client = proxy_server.prisma_client + cache = proxy_server.user_api_key_cache + span = getattr(user_api_key_dict, "parent_otel_span", None) + + # KEY: the key's own metadata (the auth object's .metadata is the team's shadow). + if user_api_key_dict.token and prisma_client is not None: + try: + key_obj = await get_key_object( + hashed_token=user_api_key_dict.token, + prisma_client=prisma_client, + user_api_key_cache=cache, + parent_otel_span=span, + proxy_logging_obj=proxy_server.proxy_logging_obj, + ) + _add(key_obj.metadata) + except Exception: + pass + + # TEAM: team_metadata is already the team's own. + _add(user_api_key_dict.team_metadata) + + # ORG: the org's own metadata (central catch-all). + if user_api_key_dict.org_id and prisma_client is not None: + try: + org_obj = await get_org_object( + org_id=user_api_key_dict.org_id, + prisma_client=prisma_client, + user_api_key_cache=cache, + parent_otel_span=span, + proxy_logging_obj=proxy_server.proxy_logging_obj, + ) + _add(getattr(org_obj, "metadata", None)) + except Exception: + pass + + return names + + +def _access_matches(access: Any, team_id: Optional[str], org_id: Optional[str]) -> bool: + """Whether an admin-owned destination's ``access`` grants this caller. + + ``global`` reaches everyone; otherwise the caller's team or org must be listed. + Per-key access is intentionally absent: a key's token rotates on regenerate, so + per-key assignment lives on the key's own ``logging_exporters`` instead. + """ + if not isinstance(access, dict): + return False + if access.get("global") is True: + return True + teams = access.get("teams") + if team_id is not None and isinstance(teams, (list, tuple)) and team_id in teams: + return True + orgs = access.get("orgs") + return org_id is not None and isinstance(orgs, (list, tuple)) and org_id in orgs + + +async def _resolve_logging_exporters( + user_api_key_dict: UserAPIKeyAuth, +) -> "tuple[list, list]": + """Resolve the destinations this request fans out to, as (destinations, backends). + + The selected set is the union of the identity chain's assigned exporter names + (key + team + org ``logging_exporters``) and every admin-owned logging destination + whose ``credential_info.access`` matches the caller (global, or the caller's team + or org). Each survivor is built via ``build_destination`` and deduped on (endpoint, + headers). The request never names or supplies a destination. Returns ([], []) only + when nothing is selected (default-deny). + """ + from litellm.integrations.otel.destinations import build_destination + + names = await _union_logging_exporter_names(user_api_key_dict) + team_id, org_id = user_api_key_dict.team_id, user_api_key_dict.org_id + + def _selected(credential: "CredentialItem") -> bool: + info = credential.credential_info or {} + if info.get("credential_type") != "logging": + return False + if credential.credential_name in names: + return True + return _access_matches(info.get("access"), team_id, org_id) + + def _build( + credential: "CredentialItem", + ) -> "Optional[tuple[str, OtelDestination]]": + backend = (credential.credential_info or {}).get("description") + if not backend: + return None + values = { + str(key): str(value) + for key, value in (credential.credential_values or {}).items() + } + destination = build_destination(backend, values) + return None if destination is None else (backend, destination) + + built = tuple( + result + for credential in litellm.credential_list + if _selected(credential) + if (result := _build(credential)) is not None + ) + deduped = { + (destination.endpoint, tuple(sorted(destination.headers.items()))): ( + backend, + destination, + ) + for backend, destination in built + } + destinations = [ + { + "callback_name": backend, + "endpoint": destination.endpoint, + "headers": destination.headers, + } + for backend, destination in deduped.values() + ] + backends = list(dict.fromkeys(backend for backend, _ in deduped.values())) + return destinations, backends + + +async def _apply_admin_logging_exporters( + data: dict, user_api_key_dict: UserAPIKeyAuth +) -> None: + """Stamp the resolved fan-out destinations onto ``data`` and activate their + backends. A client value was already stripped by the caller; default-deny means + an identity with no assignment gets no per-tenant destination here.""" + destinations, backends = await _resolve_logging_exporters(user_api_key_dict) + if not destinations: + return + data["otel_destinations"] = destinations + existing = data.get("success_callback") or [] + data["success_callback"] = list(dict.fromkeys([*existing, *backends])) + + def _get_dynamic_logging_metadata( user_api_key_dict: UserAPIKeyAuth, proxy_config: ProxyConfig ) -> Optional[TeamCallbackMetadata]: @@ -1825,6 +1982,9 @@ async def add_litellm_data_to_request( ) # Team Callbacks controls + # A client must never set or override OTEL destinations; they are admin-owned and + # resolved server-side below, so drop any value carried in the request. + data.pop("otel_destinations", None) callback_settings_obj = _get_dynamic_logging_metadata( user_api_key_dict=user_api_key_dict, proxy_config=proxy_config ) @@ -1833,10 +1993,14 @@ async def add_litellm_data_to_request( data["failure_callback"] = callback_settings_obj.failure_callback if callback_settings_obj.callback_vars is not None: - # unpack callback_vars in data for k, v in callback_settings_obj.callback_vars.items(): data[k] = v + # Admin-owned exporter assignment: resolve the union of exporters assigned across + # the request's identity chain (key + team + org) into fan-out destinations and + # activate their backends. Default-deny: an unassigned identity gets none. + await _apply_admin_logging_exporters(data, user_api_key_dict) + # Add disabled callbacks from key metadata if ( user_api_key_dict.metadata diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 2d49297c8e9..d327e4ec974 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1465,6 +1465,11 @@ async def generate_key_fn( - user_id: (str) Unique user id - used for tracking spend across multiple keys for same user id. """ try: + from litellm.proxy.management_endpoints.logging_exporter_validation import ( + validate_logging_exporter_assignment, + ) + + validate_logging_exporter_assignment(data.metadata, user_api_key_dict) from litellm.proxy._types import CommonProxyErrors from litellm.proxy.proxy_server import ( prisma_client, @@ -2510,6 +2515,11 @@ async def update_key_fn( }' ``` """ + from litellm.proxy.management_endpoints.logging_exporter_validation import ( + validate_logging_exporter_assignment, + ) + + validate_logging_exporter_assignment(data.metadata, user_api_key_dict) from litellm.proxy.proxy_server import ( llm_router, premium_user, diff --git a/litellm/proxy/management_endpoints/logging_exporter_validation.py b/litellm/proxy/management_endpoints/logging_exporter_validation.py new file mode 100644 index 00000000000..bfd3d160ccc --- /dev/null +++ b/litellm/proxy/management_endpoints/logging_exporter_validation.py @@ -0,0 +1,105 @@ +"""Validation for admin-owned logging-exporter assignment on key/team/org. + +An identity's ``metadata.logging_exporters`` binds it to admin-owned trace +destinations. Assigning is proxy-admin only, and every name must be a registered +logging credential, so a key/team/org can only point at destinations the admin has +provisioned. The resolver (``litellm_pre_call_utils``) trusts this at request time. +""" + +from typing import Optional + +from fastapi import HTTPException, status + +import litellm +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + +LOGGING_EXPORTERS_KEY = "logging_exporters" + + +def is_admin_gated_credential_info(credential_info: Optional[dict]) -> bool: + """Whether a credential write must be proxy-admin only. + + True when the credential is a logging destination or carries an ``access`` grant, + since both control where other tenants' traces are exported. + """ + if not isinstance(credential_info, dict): + return False + return ( + credential_info.get("credential_type") == "logging" + or "access" in credential_info + ) + + +def validate_credential_access(credential_info: Optional[dict]) -> None: + """Validate ``credential_info.access`` shape when the write sets one. + + No-op when ``access`` is absent. Otherwise it must be an object whose ``global`` (if + present) is a bool and whose ``teams``/``orgs`` (if present) are lists of strings. + Per-key access is intentionally unsupported on a destination. + """ + if not isinstance(credential_info, dict) or "access" not in credential_info: + return + access = credential_info["access"] + if not isinstance(access, dict): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "credential_info.access must be an object"}, + ) + if "global" in access and not isinstance(access["global"], bool): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "access.global must be a boolean"}, + ) + for field in ("teams", "orgs"): + bucket = access.get(field) + if bucket is not None and not ( + isinstance(bucket, list) and all(isinstance(item, str) for item in bucket) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": f"access.{field} must be a list of strings"}, + ) + + +def _logging_credential_names() -> set: + return { + credential.credential_name + for credential in litellm.credential_list + if (credential.credential_info or {}).get("credential_type") == "logging" + } + + +def validate_logging_exporter_assignment( + metadata: Optional[dict], user_api_key_dict: UserAPIKeyAuth +) -> None: + """Validate a ``metadata.logging_exporters`` assignment, if the update sets one. + + No-op when the update does not touch ``logging_exporters``. Otherwise it must be a + list, the caller must be the proxy admin, and every name must be a registered + logging credential. + """ + if not isinstance(metadata, dict) or LOGGING_EXPORTERS_KEY not in metadata: + return + exporters = metadata.get(LOGGING_EXPORTERS_KEY) + if not isinstance(exporters, list): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "logging_exporters must be a list of credential names"}, + ) + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"error": "Only the proxy admin can assign logging exporters"}, + ) + known = _logging_credential_names() + unknown = [name for name in exporters if name not in known] + if unknown: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": ( + f"Unknown or non-logging credential(s): {unknown}. Register them " + "as logging credentials before assigning." + ) + }, + ) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 99659121b27..00bec4a34e1 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -201,6 +201,13 @@ async def new_organization( }' ``` """ + from litellm.proxy.management_endpoints.logging_exporter_validation import ( + validate_logging_exporter_assignment, + ) + + validate_logging_exporter_assignment( + getattr(data, "metadata", None), user_api_key_dict + ) from litellm.proxy.proxy_server import ( litellm_proxy_admin_name, @@ -496,6 +503,14 @@ async def update_organization( # Create validated data model data = LiteLLM_OrganizationTableUpdate(**raw_data_with_flat_budget_fields) + from litellm.proxy.management_endpoints.logging_exporter_validation import ( + validate_logging_exporter_assignment, + ) + + validate_logging_exporter_assignment( + getattr(data, "metadata", None), user_api_key_dict + ) + # Validate budget values are not negative if data.max_budget is not None and ( not math.isfinite(data.max_budget) or data.max_budget < 0 diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 3d90e7b5ab9..059cf604cfd 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -1022,6 +1022,11 @@ async def new_team( ``` """ try: + from litellm.proxy.management_endpoints.logging_exporter_validation import ( + validate_logging_exporter_assignment, + ) + + validate_logging_exporter_assignment(data.metadata, user_api_key_dict) from litellm.proxy.management_helpers.audit_logs import ( get_audit_log_changed_by, ) @@ -1711,6 +1716,11 @@ async def update_team( ``` """ try: + from litellm.proxy.management_endpoints.logging_exporter_validation import ( + validate_logging_exporter_assignment, + ) + + validate_logging_exporter_assignment(data.metadata, user_api_key_dict) from litellm.proxy.proxy_server import ( litellm_proxy_admin_name, llm_router, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 32bfc8835fe..34c4b1f9157 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3037,6 +3037,20 @@ OPENAI_RESPONSE_HEADERS = [ ] +class OtelDestinationParams(TypedDict, total=False): + """A resolved, admin-owned OTLP destination carried server-side only. + + Populated by the proxy from the exporters assigned to a request's identity + chain; never read from a request body or metadata. The v2 logger validates and + exports through it. ``callback_name`` is the OTEL backend this destination + belongs to, so fan-out routes each destination to the right backend's logger. + """ + + callback_name: str + endpoint: str + headers: Dict[str, str] + + class StandardCallbackDynamicParams(TypedDict, total=False): # Langfuse dynamic params langfuse_public_key: Optional[str] @@ -3084,6 +3098,12 @@ class StandardCallbackDynamicParams(TypedDict, total=False): turn_off_message_logging: Optional[bool] # when true will not log messages litellm_disabled_callbacks: Optional[List[str]] + # Admin-owned OTEL v2 destinations, resolved server-side from the exporters + # assigned to the request's identity chain (key/team/user/org), fanned out to. + # Never request-settable: absent from the request-read whitelist in + # initialize_dynamic_callback_params, so a request body/metadata cannot set it. + otel_destinations: Optional[List[OtelDestinationParams]] + class CustomPricingLiteLLMParams(BaseModel): ## CUSTOM PRICING ## diff --git a/tests/test_litellm/integrations/otel/test_destinations.py b/tests/test_litellm/integrations/otel/test_destinations.py new file mode 100644 index 00000000000..7ecb674eb8b --- /dev/null +++ b/tests/test_litellm/integrations/otel/test_destinations.py @@ -0,0 +1,105 @@ +"""``build_destination`` maps an admin credential to a generic OTLP destination. + +The point of these tests is that the resolution is backend-agnostic: Langfuse, +Arize, Weave, and any raw collector all resolve to an ``{endpoint, headers}`` +the router exports through, and an incomplete credential resolves to nothing. +""" + +import base64 +import os +import sys + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.integrations.otel.destinations import ( + OTEL_V2_DESTINATION_CALLBACKS, + build_destination, +) + + +def test_langfuse_endpoint_derived_from_host_with_basic_auth(): + dest = build_destination( + "langfuse_otel", + { + "langfuse_host": "https://cloud.langfuse.com", + "langfuse_public_key": "pk-eu", + "langfuse_secret_key": "sk-eu", + }, + ) + assert dest is not None + assert dest.endpoint == "https://cloud.langfuse.com/api/public/otel" + scheme, b64 = dest.headers["Authorization"].split(" ", 1) + assert scheme == "Basic" + assert base64.b64decode(b64).decode() == "pk-eu:sk-eu" + + +def test_langfuse_bare_host_gets_https_and_path(): + dest = build_destination( + "langfuse_otel", + { + "langfuse_host": "my-langfuse.internal", + "langfuse_public_key": "pk", + "langfuse_secret_key": "sk", + }, + ) + assert dest is not None + assert dest.endpoint == "https://my-langfuse.internal/api/public/otel" + + +def test_langfuse_without_host_defaults_to_us_cloud(): + dest = build_destination( + "langfuse_otel", + {"langfuse_public_key": "pk", "langfuse_secret_key": "sk"}, + ) + assert dest is not None + assert dest.endpoint == "https://us.cloud.langfuse.com/api/public/otel" + + +def test_langfuse_incomplete_returns_none(): + assert build_destination("langfuse_otel", {"langfuse_public_key": "pk"}) is None + + +def test_arize_space_and_api_key_headers(): + dest = build_destination("arize", {"arize_space_id": "S", "arize_api_key": "K"}) + assert dest is not None + assert dest.endpoint == "https://otlp.arize.com/v1" + assert dest.headers == {"space_id": "S", "api_key": "K"} + + +def test_weave_requires_endpoint_and_key(): + assert build_destination("weave_otel", {"wandb_api_key": "w"}) is None + dest = build_destination( + "weave_otel", + { + "wandb_api_key": "w", + "weave_endpoint": "https://trace.wandb.ai/otel/v1/traces", + "weave_project_id": "entity/project", + }, + ) + assert dest is not None + assert dest.endpoint == "https://trace.wandb.ai/otel/v1/traces" + assert dest.headers["project_id"] == "entity/project" + assert "Authorization" in dest.headers + + +def test_generic_passthrough_covers_any_backend(): + dest = build_destination( + "some_self_hosted_collector", + { + "otel_endpoint": "https://collector.internal:4318/v1/traces", + "otel_headers": "x-api-key=abc,x-team=42", + }, + ) + assert dest is not None + assert dest.endpoint == "https://collector.internal:4318/v1/traces" + assert dest.headers == {"x-api-key": "abc", "x-team": "42"} + + +def test_unknown_backend_without_generic_fields_returns_none(): + assert build_destination("mystery", {"foo": "bar"}) is None + + +def test_registry_lists_the_first_class_backends(): + assert OTEL_V2_DESTINATION_CALLBACKS == frozenset( + {"langfuse_otel", "arize", "weave_otel"} + ) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py b/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py index f7c0b5452fe..3c1cfdb5e53 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py @@ -1,88 +1,97 @@ -"""Per-request multi-tenant credential routing (V1 parity).""" +"""Per-tenant tracer routing on admin-owned OTEL destinations, with fan-out. + +A request's identity chain is assigned a set of admin-owned exporters; the v2 logger +fans the trace out to all of them (plus the configured/global exporter), and never +routes on request-supplied vendor credentials. These tests lock the contract: the +request cannot route a trace, each destination's endpoint follows its resolved host +(cross-host fix), the configured exporters are kept (global also receives), and a +logger only exports the destinations tagged with its own backend. +""" import os import sys +import pytest + sys.path.insert(0, os.path.abspath("../../../..")) from opentelemetry.trace import NoOpTracer from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config -from litellm.integrations.otel.presets import dynamic_otlp_headers +from litellm.integrations.otel.model.destination import OtelDestination +from litellm.integrations.otel.model.metadata import LLMCallEvent from litellm.integrations.otel.plumbing.routing import TenantTracerCache def _cache(callback_name, exporters=None): - cfg = OpenTelemetryV2Config(exporters=exporters or [ExporterSpec(kind="in_memory")]) + cfg = OpenTelemetryV2Config( + exporters=exporters or [ExporterSpec(kind="in_memory", owner=callback_name)] + ) return TenantTracerCache(cfg, callback_name, "litellm") -# --- header builders mirror the V1 construct_dynamic_otel_headers overrides --- # - - -def test_arize_dynamic_headers(): - headers = dynamic_otlp_headers( - "arize", {"arize_space_id": "S", "arize_api_key": "K"} +def _dest(endpoint, auth="Basic AAAA", backend="langfuse_otel"): + return OtelDestination( + endpoint=endpoint, headers={"Authorization": auth}, callback_name=backend ) - assert headers == {"arize-space-id": "S", "api_key": "K"} -def test_arize_space_key_overrides_space_id(): - headers = dynamic_otlp_headers( - "arize", {"arize_space_id": "S", "arize_space_key": "SK"} +def _event(destinations): + return LLMCallEvent.from_dict( + { + "standard_callback_dynamic_params": {"otel_destinations": destinations}, + "call_type": "acompletion", + "model": "gpt-4o", + } ) - assert headers == {"arize-space-id": "SK"} -def test_langfuse_dynamic_headers_need_both_keys(): - assert dynamic_otlp_headers("langfuse_otel", {"langfuse_public_key": "pk"}) is None - headers = dynamic_otlp_headers( - "langfuse_otel", {"langfuse_public_key": "pk", "langfuse_secret_key": "sk"} - ) - assert headers is not None and "Authorization" in headers +# --- routing only happens for admin destinations --------------------------- # -def test_weave_dynamic_headers(): - headers = dynamic_otlp_headers( - "weave_otel", {"wandb_api_key": "w", "weave_project_id": "p"} - ) - assert headers is not None - assert "Authorization" in headers and headers["project_id"] == "p" - - -def test_non_participating_callbacks_have_no_routing(): - # Phoenix subclasses the base in V1 (no override) → no dynamic routing. - assert dynamic_otlp_headers("arize_phoenix", {"arize_api_key": "K"}) is None - assert dynamic_otlp_headers("langtrace", {"arize_api_key": "K"}) is None - assert dynamic_otlp_headers(None, {"arize_api_key": "K"}) is None - - -def test_no_dynamic_params_is_no_routing(): - assert dynamic_otlp_headers("arize", None) is None - assert dynamic_otlp_headers("arize", {}) is None - - -# --- TenantTracerCache routes + caches a TracerProvider per credential set --- # - - -def test_provider_cached_per_credential_set(): - cache = _cache("arize") +def test_no_destinations_uses_default_tracer(): + cache = _cache("langfuse_otel") default = NoOpTracer() - creds_a = {"arize_space_id": "S", "arize_api_key": "K"} - creds_b = {"arize_space_id": "S2", "arize_api_key": "K2"} + assert cache.tracer_for(default, ()) is default + assert cache._providers == {} - cache.tracer_for(default, creds_a) - cache.tracer_for(default, creds_a) # same set → reuse, no new provider + +def test_provider_cached_per_destination_set(): + cache = _cache("langfuse_otel") + default = NoOpTracer() + a = (_dest("https://eu.example/v1", "Basic A"),) + b = (_dest("https://eu.example/v1", "Basic B"),) + + cache.tracer_for(default, a) + cache.tracer_for(default, a) # same set -> reuse assert len(cache._providers) == 1 - cache.tracer_for(default, creds_b) # new set → new provider + cache.tracer_for(default, b) # different creds -> new provider assert len(cache._providers) == 2 +def test_different_host_is_a_distinct_provider(): + """Two destinations with identical headers but different hosts must not collide; + the cache key includes each endpoint.""" + cache = _cache("langfuse_otel") + default = NoOpTracer() + eu = (_dest("https://cloud.langfuse.com/api/public/otel", "Basic X"),) + us = (_dest("https://us.cloud.langfuse.com/api/public/otel", "Basic X"),) + cache.tracer_for(default, eu) + cache.tracer_for(default, us) + assert len(cache._providers) == 2 + + +def test_destination_set_is_order_independent(): + cache = _cache("langfuse_otel") + default = NoOpTracer() + a = _dest("https://a/v1", "Basic A") + b = _dest("https://b/v1", "Basic B") + cache.tracer_for(default, (a, b)) + cache.tracer_for(default, (b, a)) # same set, different order -> one provider + assert len(cache._providers) == 1 + + def test_provider_cache_is_bounded_and_evicts_lru(monkeypatch): - # The cache key derives from request-supplied dynamic credentials, so it - # must be bounded — an unbounded cache lets a caller spawn one provider (and - # its background exporter thread) per unique credential set. On overflow the - # least-recently-used provider is evicted and shut down. from litellm.integrations.otel.plumbing import routing as routing_mod monkeypatch.setattr(routing_mod, "_MAX_CACHED_PROVIDERS", 2) @@ -90,61 +99,49 @@ def test_provider_cache_is_bounded_and_evicts_lru(monkeypatch): monkeypatch.setattr( routing_mod, "_shutdown_provider", lambda p: shut_down.append(p) ) - - cache = _cache("arize") + cache = _cache("langfuse_otel") default = NoOpTracer() - - def creds(space): - return {"arize_space_id": space, "arize_api_key": "K"} - - cache.tracer_for(default, creds("1")) - cache.tracer_for(default, creds("2")) - cache.tracer_for(default, creds("1")) # touch "1" → "2" is now LRU - cache.tracer_for(default, creds("3")) # overflow → evict "2" - + cache.tracer_for(default, (_dest("https://1/v1"),)) + cache.tracer_for(default, (_dest("https://2/v1"),)) + cache.tracer_for(default, (_dest("https://1/v1"),)) # touch "1" -> "2" is LRU + cache.tracer_for(default, (_dest("https://3/v1"),)) # overflow -> evict "2" assert len(cache._providers) == 2 - assert len(shut_down) == 1 # exactly the evicted provider was shut down + assert len(shut_down) == 1 -def test_no_dynamic_params_uses_default_tracer(): - cache = _cache("arize") - default = NoOpTracer() - assert cache.tracer_for(default, {}) is default - assert cache._providers == {} +# --- fan-out: keep the configured exporters, append one per destination ----- # -def test_non_participating_callback_uses_default_tracer(): - cache = _cache("arize_phoenix") - default = NoOpTracer() - assert cache.tracer_for(default, {"arize_api_key": "K"}) is default - assert cache._providers == {} - - -def test_dynamic_headers_applied_to_otlp_exporter_only(): +@pytest.mark.parametrize("owner", ["langfuse_otel", "arize", "weave_otel"]) +def test_fan_out_appends_destination_with_resolved_endpoint(owner): + # The configured (global) exporter is kept; each destination is appended with its + # OWN resolved endpoint + headers (the cross-host fix, per owner). cache = _cache( - "arize", + owner, exporters=[ - ExporterSpec(kind="otlp_http", owner="arize"), - ExporterSpec(kind="in_memory", owner="arize"), + ExporterSpec( + kind="otlp_http", + endpoint="https://env-host.example/v1", + headers="Authorization=Basic ENV", + owner=owner, + ) ], ) - new_cfg = cache._config_with_headers({"arize-space-id": "S", "api_key": "K"}) - otlp, in_mem = new_cfg.exporters - assert otlp.headers == "arize-space-id=S,api_key=K" - assert in_mem.headers is None # console/in_memory left untouched + new = cache._config_with_destinations( + (_dest("https://resolved.example/v1", "Basic TEAM", backend=owner),) + ) + # global kept verbatim + assert new.exporters[0].endpoint == "https://env-host.example/v1" + assert new.exporters[0].headers == "Authorization=Basic ENV" + # destination appended at the resolved host with its own auth + assert new.exporters[-1].endpoint == "https://resolved.example/v1" + assert new.exporters[-1].headers == "Authorization=Basic TEAM" + assert len(new.exporters) == 2 -def test_dynamic_headers_do_not_leak_to_other_owners_exporter(): - """A tenant's Arize credentials must never be stamped onto a co-configured - exporter owned by a different backend (a self-hosted collector, Langfuse). - - Regression for the cross-backend credential leak: ``_config_with_headers`` - used to rewrite the headers of every OTLP exporter, so one request carrying - a team's Arize key clobbered the base collector's and Langfuse's headers - with that key. - """ +def test_fan_out_preserves_co_configured_exporters(): cache = _cache( - "arize", + "langfuse_otel", exporters=[ ExporterSpec( kind="otlp_http", @@ -154,22 +151,117 @@ def test_dynamic_headers_do_not_leak_to_other_owners_exporter(): ), ExporterSpec( kind="otlp_http", - endpoint="https://cloud.langfuse.com/api/public/otel", - headers="Authorization=Basic base-langfuse", + endpoint="https://us.cloud.langfuse.com/api/public/otel", + headers="Authorization=Basic ENV", owner="langfuse_otel", ), - ExporterSpec( - kind="otlp_grpc", - endpoint="https://otlp.arize.com/v1", - headers="space_id=base,api_key=base", - owner="arize", - ), ], ) - new_cfg = cache._config_with_headers( - {"arize-space-id": "TEAMX", "api_key": "TEAMX_KEY"} + new = cache._config_with_destinations( + (_dest("https://cloud.langfuse.com/api/public/otel", "Basic TEAM"),) ) - by_owner = {e.owner: e.headers for e in new_cfg.exporters} - assert by_owner["arize"] == "arize-space-id=TEAMX,api_key=TEAMX_KEY" - assert by_owner[None] == "x=base-collector" - assert by_owner["langfuse_otel"] == "Authorization=Basic base-langfuse" + # both originals preserved unchanged (no rewrite/leak) + assert new.exporters[0].endpoint == "http://self-hosted-collector:4318" + assert new.exporters[0].headers == "x=base-collector" + assert new.exporters[1].headers == "Authorization=Basic ENV" + # exactly one appended + assert new.exporters[-1].endpoint == "https://cloud.langfuse.com/api/public/otel" + assert len(new.exporters) == 3 + + +def test_fan_out_to_many_destinations_is_one_provider_with_all_exporters(): + cache = _cache( + "langfuse_otel", + exporters=[ + ExporterSpec( + kind="otlp_http", endpoint="https://env/v1", owner="langfuse_otel" + ) + ], + ) + new = cache._config_with_destinations( + (_dest("https://a/v1", "Basic A"), _dest("https://b/v1", "Basic B")) + ) + # global + 2 destinations -> 3 span processors, one provider, one span copied to all + assert [e.endpoint for e in new.exporters] == [ + "https://env/v1", + "https://a/v1", + "https://b/v1", + ] + cache.tracer_for(NoOpTracer(), (_dest("https://a/v1"), _dest("https://b/v1"))) + assert len(cache._providers) == 1 + + +# --- security: request credentials never route a trace --------------------- # + + +@pytest.mark.parametrize( + "request_creds", + [ + { + "langfuse_public_key": "pk-attacker", + "langfuse_secret_key": "sk-attacker", + "langfuse_host": "https://attacker.example", + }, + {"arize_api_key": "K-attacker", "arize_space_id": "S-attacker"}, + {"wandb_api_key": "w-attacker", "weave_endpoint": "https://attacker/otel"}, + ], +) +def test_request_credentials_are_inert_on_v2(request_creds): + """Any backend's credentials in the request's dynamic params (no admin + destinations) produce no per-tenant routing.""" + event = LLMCallEvent.from_dict( + { + "standard_callback_dynamic_params": request_creds, + "call_type": "acompletion", + "model": "gpt-4o", + } + ) + assert event.otel_destinations == () + cache = _cache("langfuse_otel") + default = NoOpTracer() + assert cache.tracer_for(default, event.otel_destinations) is default + assert cache._providers == {} + + +def test_admin_destinations_route(): + event = _event( + [ + { + "callback_name": "langfuse_otel", + "endpoint": "https://cloud.langfuse.com/api/public/otel", + "headers": {"Authorization": "Basic ADMIN"}, + } + ] + ) + assert len(event.otel_destinations) == 1 + cache = _cache("langfuse_otel") + cache.tracer_for(NoOpTracer(), event.otel_destinations) + assert len(cache._providers) == 1 + + +# --- a logger only exports the destinations tagged with its own backend ----- # + + +def test_logger_filters_destinations_to_its_backend(): + from litellm.integrations.otel.logger import OpenTelemetryV2 + + event = _event( + [ + { + "callback_name": "langfuse_otel", + "endpoint": "https://lf/api/public/otel", + "headers": {"Authorization": "Basic A"}, + }, + { + "callback_name": "arize", + "endpoint": "https://otlp.arize.com/v1", + "headers": {"space_id": "S"}, + }, + ] + ) + + class _Shim: + callback_name = "langfuse_otel" + + got = OpenTelemetryV2._destinations_for_backend(_Shim(), event) + assert [d.endpoint for d in got] == ["https://lf/api/public/otel"] 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 13d2ac74ad2..791bae40111 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_presets.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_presets.py @@ -44,16 +44,14 @@ def test_agentops_exporter_factory_is_registered(): assert _AGENTOPS_EXPORTER_KIND in providers._EXPORTER_FACTORIES -def test_dynamic_cred_presets_tag_exporter_with_matching_owner(monkeypatch): - """Each dynamic-credential preset must tag the exporter it contributes with - its own callback name, so per-request tenant routing - (``TenantTracerCache``) applies that integration's credentials only to its - own exporter and never bleeds them onto a co-configured backend. +def test_destination_routable_presets_tag_exporter_with_matching_owner(monkeypatch): + """Each destination-routable preset must tag the exporter it contributes with + its own callback name, so per-tenant routing (``TenantTracerCache``) points + that integration's admin destination at its own exporter only and never + rewrites a co-configured backend's exporter. """ - from litellm.integrations.otel.presets import ( - DYNAMIC_HEADERS_BY_CALLBACK, - PRESET_BY_CALLBACK, - ) + from litellm.integrations.otel.destinations import OTEL_V2_DESTINATION_CALLBACKS + from litellm.integrations.otel.presets import PRESET_BY_CALLBACK monkeypatch.setenv("ARIZE_SPACE_ID", "S") monkeypatch.setenv("ARIZE_API_KEY", "K") @@ -65,7 +63,7 @@ def test_dynamic_cred_presets_tag_exporter_with_matching_owner(monkeypatch): from litellm.integrations.otel.model.config import ExporterOwner - for callback_name in DYNAMIC_HEADERS_BY_CALLBACK: + for callback_name in OTEL_V2_DESTINATION_CALLBACKS: cfg = PRESET_BY_CALLBACK[callback_name]() owners = {e.owner for e in cfg.exporters} assert ExporterOwner(callback_name) in owners, ( diff --git a/tests/test_litellm/litellm_core_utils/test_initialize_dynamic_callback_params.py b/tests/test_litellm/litellm_core_utils/test_initialize_dynamic_callback_params.py index f63216b96d4..e4bd2f29116 100644 --- a/tests/test_litellm/litellm_core_utils/test_initialize_dynamic_callback_params.py +++ b/tests/test_litellm/litellm_core_utils/test_initialize_dynamic_callback_params.py @@ -36,6 +36,46 @@ def test_resolves_plain_values_from_metadata(): assert params.get("langfuse_host") == "https://test.langfuse.com" +def test_otel_destinations_read_from_top_level_only(): + """The admin-resolved OTEL destinations are carried server-side on top-level + kwargs (the proxy strips any client value first). They must surface on the + dynamic params so the v2 logger can fan out to them.""" + destinations = [ + { + "callback_name": "langfuse_otel", + "endpoint": "https://cloud.langfuse.com/api/public/otel", + "headers": {"Authorization": "Basic ADMIN"}, + } + ] + + params = initialize_standard_callback_dynamic_params( + {"otel_destinations": destinations} + ) + + assert params.get("otel_destinations") == destinations + + +def test_otel_destinations_never_read_from_request_metadata(): + """A request body/metadata must not be able to inject OTEL destinations: + otel_destinations is deliberately absent from the request-read whitelist, so a + value nested in metadata is ignored. Guards the trust boundary.""" + kwargs = { + "metadata": { + "otel_destinations": [ + { + "callback_name": "langfuse_otel", + "endpoint": "https://attacker.example/api/public/otel", + "headers": {"Authorization": "Basic ATTACKER"}, + } + ] + } + } + + params = initialize_standard_callback_dynamic_params(kwargs) + + assert params.get("otel_destinations") is None + + def test_env_reference_at_top_level_raises_with_guidance(): kwargs = {"langfuse_public_key": "os.environ/LANGFUSE_PUBLIC_KEY"} diff --git a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py new file mode 100644 index 00000000000..3296e1e514a --- /dev/null +++ b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py @@ -0,0 +1,205 @@ +"""Admin-gating on credential mutations for logging destinations. + +Logging credentials carry ``credential_info.access`` that controls where other +tenants' traces export, so create/update/delete of a logging credential is +proxy-admin only. Provider credentials keep their pre-existing (ungated) behavior. +""" + +import os +import sys + +import pytest +from fastapi import HTTPException +from unittest.mock import AsyncMock, MagicMock + +sys.path.insert(0, os.path.abspath("../../../..")) + +import litellm +import litellm.proxy.credential_endpoints.endpoints as endpoints +from litellm.models.credentials import CredentialItem +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.types.utils import CreateCredentialItem + + +def _admin(): + return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.PROXY_ADMIN) + + +def _member(): + return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.INTERNAL_USER) + + +_LOGGING_INFO = {"credential_type": "logging", "description": "langfuse_otel"} + + +@pytest.fixture +def _connected_db(monkeypatch): + """A working prisma_client + repository so an allowed caller reaches success.""" + import litellm.proxy.proxy_server as proxy_server + + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key") + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + monkeypatch.setattr(proxy_server, "llm_router", None) + repo = MagicMock() + repo.create = AsyncMock() + repo.delete_by_name = AsyncMock() + monkeypatch.setattr(endpoints, "CredentialsRepository", lambda _client: repo) + monkeypatch.setattr( + endpoints.CredentialAccessor, "upsert_credentials", lambda creds: None + ) + return repo + + +@pytest.mark.asyncio +async def test_create_logging_credential_forbidden_for_non_admin(_connected_db): + with pytest.raises(HTTPException) as exc: + await endpoints.create_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential=CreateCredentialItem( + credential_name="dest", + credential_values={"langfuse_host": "h"}, + credential_info=_LOGGING_INFO, + ), + user_api_key_dict=_member(), + ) + assert exc.value.status_code == 403 + _connected_db.create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_logging_credential_allowed_for_admin(_connected_db): + result = await endpoints.create_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential=CreateCredentialItem( + credential_name="dest", + credential_values={"langfuse_host": "h"}, + credential_info=_LOGGING_INFO, + ), + user_api_key_dict=_admin(), + ) + assert result["success"] is True + _connected_db.create.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_provider_credential_not_gated(_connected_db): + """A non-logging (provider) credential keeps its pre-existing ungated behavior.""" + result = await endpoints.create_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential=CreateCredentialItem( + credential_name="openai", + credential_values={"api_key": "sk"}, + credential_info={"custom_llm_provider": "openai"}, + ), + user_api_key_dict=_member(), + ) + assert result["success"] is True + + +@pytest.mark.asyncio +async def test_update_logging_credential_forbidden_for_non_admin(_connected_db): + with pytest.raises(HTTPException) as exc: + await endpoints.update_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential=CredentialItem( + credential_name="dest", + credential_values={}, + credential_info={"access": {"global": True}}, + ), + credential_name="dest", + user_api_key_dict=_member(), + ) + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_update_existing_logging_credential_forbidden_even_without_logging_patch( + _connected_db, monkeypatch +): + """A non-admin cannot edit a stored logging credential's values, even with a patch + that omits credential_info (the gate consults the in-memory credential too).""" + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="dest", + credential_values={"langfuse_host": "h"}, + credential_info=_LOGGING_INFO, + ) + ], + ) + with pytest.raises(HTTPException) as exc: + await endpoints.update_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential=CredentialItem( + credential_name="dest", + credential_values={"langfuse_host": "evil"}, + credential_info={}, + ), + credential_name="dest", + user_api_key_dict=_member(), + ) + assert exc.value.status_code == 403 + + +def test_update_db_credential_preserves_existing_info_on_partial_patch(): + """A partial credential_info patch (e.g. only access from the Edit-access modal) must + merge into the stored info, not replace it -- otherwise the logging tag is dropped and + the destination vanishes from the registry after the next reload.""" + from litellm.proxy.credential_endpoints.endpoints import update_db_credential + + db = CredentialItem( + credential_name="dest", + credential_values={}, + credential_info={ + "credential_type": "logging", + "description": "langfuse_otel", + "host": "h", + }, + ) + patch = CredentialItem( + credential_name="dest", + credential_values={}, + credential_info={"access": {"global": True}}, + ) + + merged = update_db_credential(db, patch) + + assert merged.credential_info == { + "credential_type": "logging", + "description": "langfuse_otel", + "host": "h", + "access": {"global": True}, + } + + +@pytest.mark.asyncio +async def test_delete_logging_credential_forbidden_for_non_admin( + _connected_db, monkeypatch +): + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="dest", + credential_values={}, + credential_info=_LOGGING_INFO, + ) + ], + ) + with pytest.raises(HTTPException) as exc: + await endpoints.delete_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential_name="dest", + user_api_key_dict=_member(), + ) + assert exc.value.status_code == 403 + _connected_db.delete_by_name.assert_not_awaited() diff --git a/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py new file mode 100644 index 00000000000..83bf53816bd --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py @@ -0,0 +1,136 @@ +"""Validation for admin-owned logging-exporter assignment on key/team/org.""" + +import os +import sys + +import pytest +from fastapi import HTTPException + +sys.path.insert(0, os.path.abspath("../../../..")) + +import litellm +from litellm.models.credentials import CredentialItem +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.management_endpoints.logging_exporter_validation import ( + is_admin_gated_credential_info, + validate_credential_access, + validate_logging_exporter_assignment, +) + + +@pytest.fixture +def _registry(): + original = litellm.credential_list + litellm.credential_list = [ + CredentialItem( + credential_name="langfuse-eu", + credential_values={}, + credential_info={ + "credential_type": "logging", + "description": "langfuse_otel", + }, + ), + CredentialItem( + credential_name="openai-key", + credential_values={}, + credential_info={"custom_llm_provider": "openai"}, # a provider credential + ), + ] + try: + yield + finally: + litellm.credential_list = original + + +def _admin(): + return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.PROXY_ADMIN) + + +def _member(): + return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.INTERNAL_USER) + + +def test_admin_with_known_logging_credential_is_allowed(_registry): + validate_logging_exporter_assignment( + {"logging_exporters": ["langfuse-eu"]}, _admin() + ) + + +def test_noop_when_assignment_absent(_registry): + # an update that does not touch logging_exporters is never gated/validated + validate_logging_exporter_assignment({"some_other_key": 1}, _member()) + validate_logging_exporter_assignment(None, _member()) + + +def test_non_admin_is_forbidden(_registry): + with pytest.raises(HTTPException) as exc: + validate_logging_exporter_assignment( + {"logging_exporters": ["langfuse-eu"]}, _member() + ) + assert exc.value.status_code == 403 + + +def test_unknown_credential_is_rejected(_registry): + with pytest.raises(HTTPException) as exc: + validate_logging_exporter_assignment( + {"logging_exporters": ["does-not-exist"]}, _admin() + ) + assert exc.value.status_code == 400 + + +def test_provider_credential_is_not_a_valid_logging_exporter(_registry): + # openai-key exists but is a provider credential, not a logging destination + with pytest.raises(HTTPException) as exc: + validate_logging_exporter_assignment( + {"logging_exporters": ["openai-key"]}, _admin() + ) + assert exc.value.status_code == 400 + + +def test_non_list_is_rejected(_registry): + with pytest.raises(HTTPException) as exc: + validate_logging_exporter_assignment( + {"logging_exporters": "langfuse-eu"}, _admin() + ) + assert exc.value.status_code == 400 + + +@pytest.mark.parametrize( + "credential_info, gated", + [ + ({"credential_type": "logging"}, True), + ({"access": {"global": True}}, True), + ({"credential_type": "logging", "access": {"teams": ["t"]}}, True), + ({"custom_llm_provider": "openai"}, False), + ({}, False), + (None, False), + ], +) +def test_is_admin_gated_credential_info(credential_info, gated): + assert is_admin_gated_credential_info(credential_info) is gated + + +def test_validate_credential_access_accepts_valid_object(): + validate_credential_access( + {"access": {"global": False, "teams": ["t1", "t2"], "orgs": ["o1"]}} + ) + + +def test_validate_credential_access_noop_without_access(): + validate_credential_access({"credential_type": "logging"}) + validate_credential_access(None) + + +@pytest.mark.parametrize( + "access", + [ + 5, # not an object + {"global": "yes"}, # global must be bool + {"teams": "t1"}, # teams must be a list + {"orgs": [1, 2]}, # orgs must be strings + ], +) +def test_validate_credential_access_rejects_bad_shape(access): + with pytest.raises(HTTPException) as exc: + validate_credential_access({"access": access}) + assert exc.value.status_code == 400 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 74f681bb97e..adbcf60557d 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -4798,3 +4798,243 @@ async def test_add_litellm_data_to_request_claude_code_drop_params( ) assert updated.get("drop_params") == expected_drop_params + + +@pytest.fixture +def _seeded_logging_credentials(): + from litellm.models.credentials import CredentialItem + + original = litellm.credential_list + litellm.credential_list = [ + CredentialItem( + credential_name="langfuse-eu", + credential_values={ + "langfuse_host": "https://cloud.langfuse.com", + "langfuse_public_key": "pk-eu", + "langfuse_secret_key": "sk-eu", + }, + credential_info={ + "credential_type": "logging", + "description": "langfuse_otel", + }, + ), + CredentialItem( + credential_name="arize-prod", + credential_values={"arize_space_id": "S", "arize_api_key": "K"}, + credential_info={"credential_type": "logging", "description": "arize"}, + ), + # A provider credential that must never resolve as a logging destination. + CredentialItem( + credential_name="openai-key", + credential_values={"api_key": "sk-openai"}, + credential_info={"custom_llm_provider": "openai"}, + ), + ] + try: + yield + finally: + litellm.credential_list = original + + +def _auth(team_exporters=None, token=None, org_id=None, team_id=None): + return UserAPIKeyAuth( + api_key="hashed-key", + token=token, + org_id=org_id, + team_id=team_id, + team_metadata=({"logging_exporters": team_exporters} if team_exporters else {}), + ) + + +@pytest.mark.asyncio +async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials): + # team_metadata is the team's own (not shadowed); resolves without a DB fetch. + from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters + + destinations, backends = await _resolve_logging_exporters( + _auth(team_exporters=["langfuse-eu"]) + ) + assert {d["endpoint"] for d in destinations} == { + "https://cloud.langfuse.com/api/public/otel" + } + assert backends == ["langfuse_otel"] + + +@pytest.mark.asyncio +async def test_resolve_logging_exporters_unions_key_team_org( + _seeded_logging_credentials, monkeypatch +): + # key + org are read from their OWN records (the key's .metadata is team-shadowed), + # team from team_metadata. All three union, deduped. + from types import SimpleNamespace + + import litellm.proxy.proxy_server as proxy_server + from litellm.proxy.auth import auth_checks + from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters + + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + monkeypatch.setattr( + auth_checks, + "get_key_object", + AsyncMock( + return_value=SimpleNamespace(metadata={"logging_exporters": ["arize-prod"]}) + ), + ) + monkeypatch.setattr( + auth_checks, + "get_org_object", + AsyncMock( + return_value=SimpleNamespace( + metadata={"logging_exporters": ["langfuse-eu"]} + ) + ), + ) + + destinations, backends = await _resolve_logging_exporters( + _auth(team_exporters=["langfuse-eu"], token="hashed-key", org_id="org-1") + ) + + assert {d["endpoint"] for d in destinations} == { + "https://cloud.langfuse.com/api/public/otel", # team + org (deduped) + "https://otlp.arize.com/v1", # key + } + assert set(backends) == {"langfuse_otel", "arize"} + + +@pytest.mark.asyncio +async def test_resolve_logging_exporters_empty_without_assignment( + _seeded_logging_credentials, +): + from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters + + destinations, backends = await _resolve_logging_exporters(_auth()) + assert destinations == [] and backends == [] + + +@pytest.mark.asyncio +async def test_resolve_logging_exporters_skips_unknown_and_provider_creds( + _seeded_logging_credentials, +): + from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters + + # unknown name + a provider credential (not credential_type=logging) -> nothing + destinations, backends = await _resolve_logging_exporters( + _auth(team_exporters=["does-not-exist", "openai-key"]) + ) + assert destinations == [] and backends == [] + + +@pytest.mark.asyncio +async def test_apply_admin_logging_exporters_stamps_and_activates( + _seeded_logging_credentials, +): + from litellm.proxy.litellm_pre_call_utils import _apply_admin_logging_exporters + + data: dict = {} + await _apply_admin_logging_exporters(data, _auth(team_exporters=["langfuse-eu"])) + + assert data["otel_destinations"][0]["callback_name"] == "langfuse_otel" + assert ( + data["otel_destinations"][0]["endpoint"] + == "https://cloud.langfuse.com/api/public/otel" + ) + # the backend is activated for the request + assert "langfuse_otel" in data["success_callback"] + + +_LANGFUSE_ENDPOINT = "https://cloud.langfuse.com/api/public/otel" +_ARIZE_ENDPOINT = "https://otlp.arize.com/v1" + + +@pytest.fixture +def _seeded_logging_credentials_with_access(): + """Destinations whose access lives ON the credential (global/team/org), in + addition to a name-only destination assigned via the identity chain.""" + from litellm.models.credentials import CredentialItem + + original = litellm.credential_list + litellm.credential_list = [ + CredentialItem( + credential_name="langfuse-eu", + credential_values={ + "langfuse_host": "https://cloud.langfuse.com", + "langfuse_public_key": "pk-eu", + "langfuse_secret_key": "sk-eu", + }, + credential_info={ + "credential_type": "logging", + "description": "langfuse_otel", + "access": {"teams": ["team-eu"], "orgs": ["org-eu"]}, + }, + ), + CredentialItem( + credential_name="arize-global", + credential_values={"arize_space_id": "S", "arize_api_key": "K"}, + credential_info={ + "credential_type": "logging", + "description": "arize", + "access": {"global": True}, + }, + ), + ] + try: + yield + finally: + litellm.credential_list = original + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "auth_kwargs, expected_endpoints", + [ + # access.global reaches an unassigned caller (no names at all). This case + # fails if the resolver early-returns on empty identity names. + pytest.param({}, {_ARIZE_ENDPOINT}, id="access-global-unassigned"), + # access.teams matches the caller's team_id (no identity names). + pytest.param( + {"team_id": "team-eu"}, + {_ARIZE_ENDPOINT, _LANGFUSE_ENDPOINT}, + id="access-team-match", + ), + # a different team gets only the global destination. + pytest.param( + {"team_id": "team-other"}, {_ARIZE_ENDPOINT}, id="access-team-mismatch" + ), + # access.orgs matches the caller's org_id. + pytest.param( + {"org_id": "org-eu"}, + {_ARIZE_ENDPOINT, _LANGFUSE_ENDPOINT}, + id="access-org-match", + ), + # identity name AND access point at the same destination -> deduped to one + # (plus the always-on global). team-eu reaches langfuse via BOTH paths. + pytest.param( + {"team_id": "team-eu", "team_exporters": ["langfuse-eu"]}, + {_ARIZE_ENDPOINT, _LANGFUSE_ENDPOINT}, + id="both-paths-deduped", + ), + ], +) +async def test_resolve_logging_exporters_access_matrix( + _seeded_logging_credentials_with_access, auth_kwargs, expected_endpoints +): + from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters + + destinations, _ = await _resolve_logging_exporters(_auth(**auth_kwargs)) + assert {d["endpoint"] for d in destinations} == expected_endpoints + # no duplicate destinations survive the union + assert len(destinations) == len(expected_endpoints) + + +@pytest.mark.asyncio +async def test_resolve_logging_exporters_access_default_deny( + _seeded_logging_credentials, +): + """With no access grants and no identity assignment, nothing resolves -- the + global-access path must not invent a destination out of name-only creds.""" + from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters + + destinations, backends = await _resolve_logging_exporters( + _auth(team_id="team-eu", org_id="org-eu") + ) + assert destinations == [] and backends == [] diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx index 0533b98b762..63ad4f47cfe 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx @@ -1,5 +1,5 @@ -import { render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { LoggingCallbacksTable } from "./LoggingCallbacksTable"; describe("LoggingCallbacksTable", () => { @@ -90,4 +90,89 @@ describe("LoggingCallbacksTable", () => { expect(getByText("Success")).toBeInTheDocument(); expect(getByText("Failure")).toBeInTheDocument(); }); + + const NO_VARS = { + SLACK_WEBHOOK_URL: null, + LANGFUSE_PUBLIC_KEY: null, + LANGFUSE_SECRET_KEY: null, + LANGFUSE_HOST: null, + OPENMETER_API_KEY: null, + }; + + it("summarizes a global destination's access as Global with no mode badge", () => { + const { getByText, queryByText } = render( + , + ); + expect(getByText("Global")).toBeInTheDocument(); + // a destination has no success/failure mode badge + expect(queryByText("Success")).not.toBeInTheDocument(); + }); + + it("summarizes team/org counts for a scoped destination", () => { + const { getByText } = render( + , + ); + expect(getByText("2 teams · 1 org")).toBeInTheDocument(); + }); + + it("a destination row fires onEditAccess and onDelete, never onTest", () => { + const onEditAccess = vi.fn(); + const onDelete = vi.fn(); + const onTest = vi.fn(); + const { getByText } = render( + , + ); + const row = getByText("dest").closest("tr") as HTMLElement; + const scoped = within(row); + // destination rows expose edit-access + delete, and no test action + expect(scoped.queryByTestId("test-callback")).not.toBeInTheDocument(); + fireEvent.click(scoped.getByTestId("edit-access")); + fireEvent.click(scoped.getByTestId("delete-destination")); + expect(onEditAccess).toHaveBeenCalledWith(expect.objectContaining({ credentialName: "dest" })); + expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ credentialName: "dest" })); + expect(onTest).not.toHaveBeenCalled(); + }); + + it("a config callback row keeps the test/edit/delete actions and an em-dash access", () => { + const { getByText } = render( + , + ); + const row = getByText("datadog").closest("tr") as HTMLElement; + const scoped = within(row); + expect(scoped.getByTestId("test-callback")).toBeInTheDocument(); + expect(scoped.getByTestId("edit-callback")).toBeInTheDocument(); + expect(scoped.getByTestId("delete-callback")).toBeInTheDocument(); + expect(scoped.queryByTestId("edit-access")).not.toBeInTheDocument(); + // access cell renders an em-dash for non-destination rows + expect(scoped.getByText("—")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx index 70ec6599ca2..2c66ed3f5f3 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx @@ -19,9 +19,23 @@ type LoggingCallbacksProps = { onTest?: (callback: AlertingObject) => void | Promise; onEdit?: (callback: AlertingObject) => void; onDelete?: (callback: AlertingObject) => void; + onEditAccess?: (callback: AlertingObject) => void; onAdd?: () => void; }; +const isDestination = (record: AlertingObject): boolean => record.credentialName != null; + +const accessSummary = (record: AlertingObject): string => { + const access = record.access; + if (!access) return "—"; + if (access.global) return "Global"; + const parts = [ + access.teams?.length ? `${access.teams.length} team${access.teams.length > 1 ? "s" : ""}` : null, + access.orgs?.length ? `${access.orgs.length} org${access.orgs.length > 1 ? "s" : ""}` : null, + ].filter(Boolean); + return parts.length ? parts.join(" · ") : "—"; +}; + type CallbackRow = AlertingObject & { id?: string; mode?: "success" | "failure" | "info" | string; @@ -39,6 +53,7 @@ export const LoggingCallbacksTable: React.FC = ({ onTest = () => {}, onEdit = () => {}, onDelete = () => {}, + onEditAccess = () => {}, onAdd = () => {}, }) => { const columns: TableProps["columns"] = [ @@ -49,13 +64,21 @@ export const LoggingCallbacksTable: React.FC = ({ render: (_: string, record: CallbackRow) => { const id = record.name; const displayName = availableCallbacks[id]?.ui_callback_name || id; - return
{displayName}
; + return ( +
+
{displayName}
+ {record.destinationLabel &&
{record.destinationLabel}
} +
+ ); }, }, { title: Mode, key: "mode", render: (_: unknown, record: CallbackRow) => { + // Destination rows fan out on every span, so the success/failure split + // does not apply -- only config callbacks carry a mode. + if (isDestination(record)) return —; // Backend sends `type` (success | failure); legacy in-memory rows // from add-callback flow set `mode`. Read both so newly-added rows // and server-fetched rows both render correctly. @@ -73,20 +96,62 @@ export const LoggingCallbacksTable: React.FC = ({ ); }, - width: 240, + width: 200, + }, + { + title: Scope, + key: "access", + render: (_: unknown, record: CallbackRow) => + isDestination(record) ? ( + {accessSummary(record)} + ) : ( + — + ), + width: 160, }, { title: Actions, key: "actions", align: "right", - render: (_: unknown, record: CallbackRow) => ( -
- onTest(record)} /> - onEdit(record)} /> - onDelete(record)} /> -
- ), - width: 240, + render: (_: unknown, record: CallbackRow) => + isDestination(record) ? ( +
+ onEditAccess(record)} + /> + onDelete(record)} + /> +
+ ) : ( +
+ onTest(record)} + /> + onEdit(record)} + /> + onDelete(record)} + /> +
+ ), + width: 200, }, ]; return ( diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts index 5d265f95484..42fdf5d3de7 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts @@ -8,6 +8,18 @@ export interface AlertingObject { // every row to render as "Success". type?: "success" | "failure" | "success_and_failure"; variables: AlertingVariables; + // Present only on rows backed by a logging credential (an OTEL trace + // destination). Config-callback rows leave these unset, which is how the table + // tells the two apart. + credentialName?: string; + destinationLabel?: string; + access?: CredentialAccess; +} + +export interface CredentialAccess { + global?: boolean; + teams?: string[]; + orgs?: string[]; } export interface AlertingVariables { diff --git a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx index 0334c55f66c..30cbe4b140c 100644 --- a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx @@ -3,7 +3,7 @@ interface CallbackConfig { displayName: string; logo: string; supports_key_team_logging: boolean; - dynamic_params: Record; + dynamic_params: Record; description: string; } @@ -14,11 +14,10 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [ id: "arize", displayName: "Arize", logo: `${asset_logos_folder}arize.png`, - supports_key_team_logging: true, - dynamic_params: { - arize_api_key: "password", - arize_space_id: "password", - }, + // OTEL v2 destination: assigned per identity via the "Logging Exporters" field + // (metadata.logging_exporters), not configured as a per-team callback here. + supports_key_team_logging: false, + dynamic_params: {}, description: "Arize Logging Integration", }, { @@ -96,14 +95,22 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [ id: "langfuse_otel", displayName: "Langfuse OTEL", logo: `${asset_logos_folder}langfuse.png`, - supports_key_team_logging: true, - dynamic_params: { - langfuse_public_key: "text", - langfuse_secret_key: "password", - langfuse_host: "text", - }, + // OTEL v2 destination: assigned per identity via the "Logging Exporters" field + // (metadata.logging_exporters), not configured as a per-team callback here. + supports_key_team_logging: false, + dynamic_params: {}, description: "Langfuse v3 OTEL Logging Integration", }, + { + id: "weave_otel", + displayName: "Weave OTEL", + logo: `${asset_logos_folder}weave.png`, + // OTEL v2 destination: assigned per identity via the "Logging Exporters" field + // (metadata.logging_exporters), not configured as a per-team callback here. + supports_key_team_logging: false, + dynamic_params: {}, + description: "Weave (W&B) OTEL Logging Integration", + }, { id: "langsmith", displayName: "LangSmith", diff --git a/ui/litellm-dashboard/src/components/logging_credentials/AccessControlFields.tsx b/ui/litellm-dashboard/src/components/logging_credentials/AccessControlFields.tsx new file mode 100644 index 00000000000..0569841f305 --- /dev/null +++ b/ui/litellm-dashboard/src/components/logging_credentials/AccessControlFields.tsx @@ -0,0 +1,67 @@ +import { Form, Select, Switch } from "antd"; +import React from "react"; + +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types"; + +interface AccessControlFieldsProps { + // value/onChange are optional so the component can be driven either directly + // (the Add modal) or injected by an antd Form.Item (the Edit modal). + value?: CredentialAccess; + onChange?: (next: CredentialAccess) => void; +} + +// Admin-owned access for a logging destination: global (every request) or a set of +// teams/orgs. Per-key targeting is intentionally absent here -- it lives on the key's +// own page, since a key's token rotates on regenerate while team/org ids are stable. +const AccessControlFields: React.FC = ({ value = {}, onChange = () => {} }) => { + const { data: teams } = useTeams(); + const { data: orgs } = useOrganizations(); + const isGlobal = value.global === true; + + const teamOptions = (teams ?? []).map((t) => ({ value: t.team_id, label: t.team_alias || t.team_id })); + const orgOptions = (orgs ?? []).map((o) => ({ + value: o.organization_id, + label: o.organization_alias || o.organization_id, + })); + + return ( + <> + + onChange({ ...value, global })} /> + + + onChange({ ...value, orgs: orgIds })} + options={orgOptions} + optionFilterProp="label" + style={{ width: "100%" }} + /> + + + ); +}; + +export default AccessControlFields; diff --git a/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx b/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx new file mode 100644 index 00000000000..79680a5740a --- /dev/null +++ b/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx @@ -0,0 +1,74 @@ +import { Form, Modal } from "antd"; +import React from "react"; + +import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types"; +import NotificationsManager from "../molecules/notifications_manager"; +import { credentialUpdateCall } from "../networking"; +import AccessControlFields from "./AccessControlFields"; + +interface EditLoggingCredentialModalProps { + accessToken: string; + credentialName: string | null; + access?: CredentialAccess; + open: boolean; + onClose: () => void; + onSaved: () => void; +} + +interface AccessForm { + access?: CredentialAccess; +} + +const EditLoggingCredentialModal: React.FC = ({ + accessToken, + credentialName, + access, + open, + onClose, + onSaved, +}) => { + // destroyOnClose remounts the Form each open, so initialValues re-seeds from the + // current destination -- no effect syncing prop into state. + const [form] = Form.useForm(); + + const handleSave = async () => { + if (!credentialName) return; + const current = form.getFieldsValue().access ?? {}; + // Always send the full access object: credential_info merges server-side, so a + // sparse patch could never clear a bucket. A global grant supersedes team/org. + const next: CredentialAccess = current.global + ? { global: true, teams: [], orgs: [] } + : { global: false, teams: current.teams ?? [], orgs: current.orgs ?? [] }; + try { + await credentialUpdateCall(accessToken, credentialName, { + credential_name: credentialName, + credential_values: {}, + credential_info: { access: next }, + }); + NotificationsManager.success("Access updated"); + onSaved(); + onClose(); + } catch (error) { + NotificationsManager.fromBackend(error instanceof Error ? error.message : String(error)); + } + }; + + return ( + + form={form} layout="vertical" preserve={false} initialValues={{ access: access ?? {} }}> + + + + + + ); +}; + +export default EditLoggingCredentialModal; diff --git a/ui/litellm-dashboard/src/components/logging_credentials/LoggingExportersSelect.tsx b/ui/litellm-dashboard/src/components/logging_credentials/LoggingExportersSelect.tsx new file mode 100644 index 00000000000..32bbc8aa178 --- /dev/null +++ b/ui/litellm-dashboard/src/components/logging_credentials/LoggingExportersSelect.tsx @@ -0,0 +1,43 @@ +import { Select } from "antd"; +import React from "react"; + +import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; + +interface LoggingExportersSelectProps { + value?: string[]; + onChange?: (value: string[]) => void; +} + +/** + * Multi-select of admin-owned logging destinations (credential_type=logging) that an + * identity (key / team / org) exports its traces to. The selected names are stored in + * metadata.logging_exporters; the proxy unions them across the identity chain and fans + * out. Sourced from the same registry, filtered to logging credentials only. + */ +const LoggingExportersSelect: React.FC = ({ value, onChange }) => { + const { data } = useCredentials(); + const options = (data?.credentials ?? []) + .filter((credential) => credential.credential_info?.credential_type === "logging") + .map((credential) => ({ + value: credential.credential_name, + label: credential.credential_info?.host + ? `${credential.credential_name} (${credential.credential_info.host})` + : credential.credential_name, + })); + + return ( + + + {addingDestinationFields.map((f) => ( + {f.label}} + name={f.name} + rules={f.optional ? undefined : [{ required: true, message: `Please enter the ${f.label.toLowerCase()}` }]} + > + {f.type === "password" ? : } + + ))} + + + ) : ( + + )}
= ({ accessToken, userRole, userID, setShowAddCallbacksModal(false); setSelectedCallback(null); setSelectedCallbackParams([]); + setAddAccess({}); addForm.resetFields(); }} disabled={isAddingCallback} @@ -747,7 +861,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, Cancel - {isAddingCallback ? "Adding..." : "Add Callback"} + {isAddingCallback ? "Adding..." : "Add"}
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 602490a0c98..196d6242e44 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -51,6 +51,7 @@ import NumericalInput from "../shared/numerical_input"; import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; import SearchToolSelector from "../SearchTools/SearchToolSelector"; import EditLoggingSettings from "./EditLoggingSettings"; +import LoggingExportersSelect from "../logging_credentials/LoggingExportersSelect"; import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion"; import MemberModal from "./EditMembership"; import MemberPermissions from "./member_permissions"; @@ -530,6 +531,9 @@ const TeamInfoView: React.FC = ({ guardrails: (values.guardrails || []).filter((n: string) => !globalGuardrailNames.has(n)), opted_out_global_guardrails: optedOutGlobalGuardrails, ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), + ...(values.logging_exporters !== undefined + ? { logging_exporters: values.logging_exporters } + : {}), disable_global_guardrails: killSwitchOnAtSave, soft_budget_alerting_emails: typeof values.soft_budget_alerting_emails === "string" @@ -862,6 +866,9 @@ const TeamInfoView: React.FC = ({ @@ -974,6 +981,7 @@ const TeamInfoView: React.FC = ({ ) : "", logging_settings: info.metadata?.logging || [], + logging_exporters: info.metadata?.logging_exporters || [], secret_manager_settings: info.metadata?.secret_manager_settings ? JSON.stringify(info.metadata.secret_manager_settings, null, 2) : "", @@ -1425,6 +1433,14 @@ const TeamInfoView: React.FC = ({ /> + + + + = ({ )} + + + + 0 ? { logging: formValues.logging_settings } : {}), + ...(formValues.logging_exporters !== undefined + ? { logging_exporters: formValues.logging_exporters } + : {}), ...(formValues.disabled_callbacks?.length > 0 ? { litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks), @@ -275,6 +278,9 @@ export default function KeyInfoView({ ...(Array.isArray(formValues.logging_settings) && formValues.logging_settings.length > 0 ? { logging: formValues.logging_settings } : {}), + ...(formValues.logging_exporters !== undefined + ? { logging_exporters: formValues.logging_exporters } + : {}), ...(formValues.disabled_callbacks?.length > 0 ? { litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks), @@ -613,6 +619,11 @@ export default function KeyInfoView({