feat(otel/v2): admin-owned, identity-scoped trace destinations

OTEL v2 trace destinations are admin-owned and resolved server-side from the
authenticated identity; nothing in an inference request (body, headers, or metadata)
can set or select where a trace goes. Resolves LIT-3850.

A destination is the universal OTLP target (endpoint + headers). The admin registers
destinations as named logging credentials and assigns them to a virtual key, team, or
organization via metadata.logging_exporters, or marks a destination's scope as
global / team-set / org-set. At request time the proxy unions exporters assigned
across the identity chain (key + team + org, each read from its own record),
resolves them to destinations, and fans the trace out to all of them. An identity
with no assignment gets no per-tenant destination (default-deny). The resolved
destinations ride a server-only field of standard_callback_dynamic_params, absent
from the request-read whitelist.

Assignment is admin-owned: metadata.logging_exporters on a key, team, or org is
proxy-admin only and every name must be a registered logging credential, validated
in the create and update handlers for all three. Credential create/update/delete are
proxy-admin gated when the credential is a logging destination.

UI: OTEL destinations live as rows in the one Active Logging Callbacks table with a
Scope column and an Edit-scope modal (global / teams / orgs); the existing Add
Callback modal is extended to also create destinations inline; the key, team, and
org forms gain a Logging Exporters multi-select for identity-side assignment, and
their overview views render the assigned exporters inside the Logging Settings card.

Out of scope: a credential_type column (today the logging tag lives in the free-form
credential_info, matching the existing custom_llm_provider discriminator on cursor
pass-through credentials, no migration); per-key access from the destination side;
routing the request root span through the same per-tenant destinations for
full-trace parity.

The rebuilt UI bundle ships via the separate chore(ui): rebuild ui PR cycle.
This commit is contained in:
yucheng-berriai 2026-06-22 14:45:01 -07:00
parent 8bc18388e3
commit a90eff3d5b
44 changed files with 2340 additions and 308 deletions

4
.gitignore vendored
View file

@ -123,3 +123,7 @@ crash.*.log
# and should be committed.
.vscode
.pin_list.txt
repro_lit3850/
# throwaway learning scripts (not a feature)
scratch/

View file

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

View file

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

View file

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

View file

@ -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)
),
)
# ====================================================================== #

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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."
)
},
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 == []

View file

@ -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(
<LoggingCallbacksTable
callbacks={[
{
name: "langfuse-eu",
variables: NO_VARS,
credentialName: "langfuse-eu",
access: { global: true },
},
]}
availableCallbacks={{}}
/>,
);
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(
<LoggingCallbacksTable
callbacks={[
{
name: "arize-eu",
variables: NO_VARS,
credentialName: "arize-eu",
access: { teams: ["t1", "t2"], orgs: ["o1"] },
},
]}
availableCallbacks={{}}
/>,
);
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(
<LoggingCallbacksTable
callbacks={[{ name: "dest", variables: NO_VARS, credentialName: "dest", access: { global: true } }]}
availableCallbacks={{}}
onEditAccess={onEditAccess}
onDelete={onDelete}
onTest={onTest}
/>,
);
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(
<LoggingCallbacksTable
callbacks={[{ name: "datadog", type: "success", variables: NO_VARS }]}
availableCallbacks={{}}
/>,
);
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();
});
});

View file

@ -19,9 +19,23 @@ type LoggingCallbacksProps = {
onTest?: (callback: AlertingObject) => void | Promise<void>;
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<LoggingCallbacksProps> = ({
onTest = () => {},
onEdit = () => {},
onDelete = () => {},
onEditAccess = () => {},
onAdd = () => {},
}) => {
const columns: TableProps<CallbackRow>["columns"] = [
@ -49,13 +64,21 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
render: (_: string, record: CallbackRow) => {
const id = record.name;
const displayName = availableCallbacks[id]?.ui_callback_name || id;
return <div className="font-medium text-gray-800">{displayName}</div>;
return (
<div>
<div className="font-medium text-gray-800">{displayName}</div>
{record.destinationLabel && <div className="text-xs text-gray-500">{record.destinationLabel}</div>}
</div>
);
},
},
{
title: <span className="font-medium text-gray-700">Mode</span>,
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 <span className="text-gray-400">—</span>;
// 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<LoggingCallbacksProps> = ({
</span>
);
},
width: 240,
width: 200,
},
{
title: <span className="font-medium text-gray-700">Scope</span>,
key: "access",
render: (_: unknown, record: CallbackRow) =>
isDestination(record) ? (
<span className="text-sm text-gray-700">{accessSummary(record)}</span>
) : (
<span className="text-gray-400">—</span>
),
width: 160,
},
{
title: <span className="font-medium text-gray-700 text-right w-full block">Actions</span>,
key: "actions",
align: "right",
render: (_: unknown, record: CallbackRow) => (
<div className="flex justify-end gap-2">
<TableIconActionButton variant="Test" tooltipText="Test Callback" onClick={() => onTest(record)} />
<TableIconActionButton variant="Edit" tooltipText="Edit Callback" onClick={() => onEdit(record)} />
<TableIconActionButton variant="Delete" tooltipText="Delete Callback" onClick={() => onDelete(record)} />
</div>
),
width: 240,
render: (_: unknown, record: CallbackRow) =>
isDestination(record) ? (
<div className="flex justify-end gap-2">
<TableIconActionButton
variant="Edit"
tooltipText="Edit scope"
dataTestId="edit-access"
onClick={() => onEditAccess(record)}
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete destination"
dataTestId="delete-destination"
onClick={() => onDelete(record)}
/>
</div>
) : (
<div className="flex justify-end gap-2">
<TableIconActionButton
variant="Test"
tooltipText="Test Callback"
dataTestId="test-callback"
onClick={() => onTest(record)}
/>
<TableIconActionButton
variant="Edit"
tooltipText="Edit Callback"
dataTestId="edit-callback"
onClick={() => onEdit(record)}
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete Callback"
dataTestId="delete-callback"
onClick={() => onDelete(record)}
/>
</div>
),
width: 200,
},
];
return (

View file

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

View file

@ -3,7 +3,7 @@ interface CallbackConfig {
displayName: string;
logo: string;
supports_key_team_logging: boolean;
dynamic_params: Record<string, "text" | "password" | "select" | "upload" | "number">;
dynamic_params: Record<string, "text" | "password" | "select" | "upload" | "number" | "credential">;
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",

View file

@ -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<AccessControlFieldsProps> = ({ 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 (
<>
<Form.Item
label="Global"
tooltip="When on, every request's traces export to this destination, regardless of key, team, or org."
>
<Switch checked={isGlobal} onChange={(global) => onChange({ ...value, global })} />
</Form.Item>
<Form.Item label="Teams" tooltip="Requests from keys in these teams export to this destination.">
<Select
mode="multiple"
allowClear
disabled={isGlobal}
placeholder="Select teams"
value={value.teams ?? []}
onChange={(teamIds) => onChange({ ...value, teams: teamIds })}
options={teamOptions}
optionFilterProp="label"
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item label="Organizations" tooltip="Requests under these orgs export to this destination.">
<Select
mode="multiple"
allowClear
disabled={isGlobal}
placeholder="Select organizations"
value={value.orgs ?? []}
onChange={(orgIds) => onChange({ ...value, orgs: orgIds })}
options={orgOptions}
optionFilterProp="label"
style={{ width: "100%" }}
/>
</Form.Item>
</>
);
};
export default AccessControlFields;

View file

@ -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<EditLoggingCredentialModalProps> = ({
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<AccessForm>();
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 (
<Modal
title={`Edit scope${credentialName ? ` — ${credentialName}` : ""}`}
open={open}
onCancel={onClose}
onOk={handleSave}
okText="Save"
destroyOnClose
>
<Form<AccessForm> form={form} layout="vertical" preserve={false} initialValues={{ access: access ?? {} }}>
<Form.Item name="access" noStyle>
<AccessControlFields />
</Form.Item>
</Form>
</Modal>
);
};
export default EditLoggingCredentialModal;

View file

@ -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<LoggingExportersSelectProps> = ({ 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 (
<Select
mode="multiple"
allowClear
placeholder="Select logging destinations this identity exports to"
value={value}
onChange={onChange}
options={options}
style={{ width: "100%" }}
optionFilterProp="label"
notFoundContent="No logging destinations. Add one under Settings -> Logging Callbacks."
/>
);
};
export default LoggingExportersSelect;

View file

@ -0,0 +1,42 @@
import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { credentialCreateCall } from "../networking";
import { LOGGING_DESTINATION_BACKENDS } from "./loggingDestinationFields";
// The set of OTEL backend ids that are created as logging destinations (credentials),
// not as global config callbacks. The unified Add modal branches on this.
export const LOGGING_BACKEND_IDS: ReadonlySet<string> = new Set(LOGGING_DESTINATION_BACKENDS.map((b) => b.id));
// Callback ids that must not surface as global callbacks. Per LIT-3850 OTEL is admin-
// owned and routed per identity via trace destinations, and the legacy Langfuse/OTEL
// callback paths (`langfuse` v2 SDK, `langfuse_otel` v1, the generic `otel` callback)
// are deprecated, so these are only ever destinations -- never callback rows or options.
export const NON_CALLBACK_LOGGING_IDS: ReadonlySet<string> = new Set([
...LOGGING_DESTINATION_BACKENDS.map((b) => b.id),
"langfuse",
"otel",
]);
export const backendLabel = (id?: string): string =>
LOGGING_DESTINATION_BACKENDS.find((b) => b.id === id)?.label ?? id ?? "-";
export interface CreateLoggingCredentialInput {
credentialName: string;
backend: string;
values: Record<string, string>;
host?: string;
access?: CredentialAccess;
}
// One place that owns the logging-credential contract: the credential_type tag, the
// backend in description, the non-secret host, and the admin-owned access grant.
export const createLoggingCredential = async (accessToken: string, input: CreateLoggingCredentialInput) =>
credentialCreateCall(accessToken, {
credential_name: input.credentialName,
credential_values: input.values,
credential_info: {
credential_type: "logging",
description: input.backend,
...(input.host ? { host: input.host } : {}),
...(input.access ? { access: input.access } : {}),
},
});

View file

@ -0,0 +1,65 @@
// Create-time field shapes for an admin-owned logging destination, keyed by the
// OTEL v2 backend it binds to. This is the inverse of the per-team picker: the
// picker selects a destination by name; these fields are what an admin types when
// CREATING the named destination in the registry. Keeping the raw keys here (the
// admin registry) and out of the per-team form is the provider/logging separation.
export type LoggingFieldType = "text" | "password";
export interface LoggingField {
name: string;
label: string;
type: LoggingFieldType;
optional?: boolean;
}
export interface LoggingDestinationBackend {
id: string; // the callback_name the credential is bound under
label: string;
fields: LoggingField[];
// The non-secret field that names the destination host/endpoint. Surfaced in the
// list so an admin can tell e.g. an EU from a US destination apart.
hostField: string;
}
export const LOGGING_DESTINATION_BACKENDS: LoggingDestinationBackend[] = [
{
id: "langfuse_otel",
label: "Langfuse",
fields: [
{ name: "langfuse_host", label: "Langfuse Host", type: "text" },
{ name: "langfuse_public_key", label: "Public Key", type: "password" },
{ name: "langfuse_secret_key", label: "Secret Key", type: "password" },
],
hostField: "langfuse_host",
},
{
id: "arize",
label: "Arize",
fields: [
{ name: "arize_space_id", label: "Space ID", type: "password" },
{ name: "arize_api_key", label: "API Key", type: "password" },
{ name: "arize_endpoint", label: "Endpoint (optional)", type: "text", optional: true },
],
hostField: "arize_endpoint",
},
{
id: "weave_otel",
label: "Weave",
fields: [
{ name: "wandb_api_key", label: "W&B API Key", type: "password" },
{ name: "weave_endpoint", label: "Weave OTEL Endpoint", type: "text" },
{ name: "weave_project_id", label: "Project (entity/project)", type: "text" },
],
hostField: "weave_endpoint",
},
{
id: "generic",
label: "Generic OTLP Collector",
fields: [
{ name: "otel_endpoint", label: "OTLP Endpoint", type: "text" },
{ name: "otel_headers", label: "Headers (k=v,k2=v2)", type: "text", optional: true },
],
hostField: "otel_endpoint",
},
];

View file

@ -12,6 +12,7 @@ interface LoggingConfig {
interface LoggingSettingsViewProps {
loggingConfigs?: LoggingConfig[];
disabledCallbacks?: string[];
loggingExporters?: string[];
variant?: "card" | "inline";
className?: string;
}
@ -19,6 +20,7 @@ interface LoggingSettingsViewProps {
export function LoggingSettingsView({
loggingConfigs = [],
disabledCallbacks = [],
loggingExporters = [],
variant = "card",
className = "",
}: LoggingSettingsViewProps) {
@ -56,6 +58,29 @@ export function LoggingSettingsView({
const content = (
<div className="space-y-6">
{/* Logging Exporters (admin-owned OTEL trace destinations assigned to this identity) */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<CogIcon className="h-4 w-4 text-blue-600" />
<span className="font-semibold text-gray-900">Logging Exporters</span>
<Tag color="blue">{loggingExporters.length}</Tag>
</div>
{loggingExporters.length > 0 ? (
<div className="flex flex-wrap gap-2">
{loggingExporters.map((name, index) => (
<Tag key={index} color="blue">
{name}
</Tag>
))}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
<CogIcon className="h-4 w-4 text-gray-400" />
<span className="text-gray-500 text-sm">No logging exporters assigned</span>
</div>
)}
</div>
{/* Logging Integrations Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">

View file

@ -209,6 +209,18 @@ export interface CredentialItem {
custom_llm_provider?: string;
description?: string;
required?: boolean;
// "logging" tags an admin-owned trace destination (Option A: lives in the
// free-form credential_info, no schema migration). Absent = a provider credential.
credential_type?: string;
// Non-secret destination host/endpoint, surfaced in the logging credentials list.
host?: string;
// Admin-owned access grant for a logging destination: which identities its
// traces fan out to. global reaches everyone; teams/orgs list ids.
access?: {
global?: boolean;
teams?: string[];
orgs?: string[];
};
};
}

View file

@ -12,6 +12,7 @@ import React, { useMemo, useState } from "react";
import MemberTable from "../common_components/MemberTable";
import UserSearchModal from "../common_components/user_search_modal";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import LoggingExportersSelect from "../logging_credentials/LoggingExportersSelect";
import { ModelSelect } from "../ModelSelect/ModelSelect";
import NotificationsManager from "../molecules/notifications_manager";
import {
@ -134,7 +135,12 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
max_budget: values.max_budget,
budget_duration: values.budget_duration,
},
metadata: values.metadata ? JSON.parse(values.metadata) : null,
metadata: {
...(values.metadata ? JSON.parse(values.metadata) : {}),
...(values.logging_exporters !== undefined
? { logging_exporters: values.logging_exporters }
: {}),
},
};
// Handle object_permission updates
@ -308,6 +314,21 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
))}
</div>
</Card>
<Card>
<Text>Logging Exporters</Text>
<div className="mt-2 flex flex-wrap gap-2">
{Array.isArray(orgData.metadata?.logging_exporters) &&
orgData.metadata.logging_exporters.length > 0 ? (
orgData.metadata.logging_exporters.map((name: string, index: number) => (
<Badge key={index} color="blue">
{name}
</Badge>
))
) : (
<Text className="text-gray-400">None</Text>
)}
</div>
</Card>
<ObjectPermissionsView
objectPermission={orgData.object_permission}
@ -366,6 +387,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
max_budget: orgData.litellm_budget_table.max_budget,
budget_duration: orgData.litellm_budget_table.budget_duration,
metadata: orgData.metadata ? JSON.stringify(orgData.metadata, null, 2) : "",
logging_exporters: orgData.metadata?.logging_exporters || [],
vector_stores: orgData.object_permission?.vector_stores || [],
mcp_servers_and_groups: {
servers: orgData.object_permission?.mcp_servers || [],
@ -437,6 +459,14 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
/>
</Form.Item>
<Form.Item
label="Logging Exporters"
name="logging_exporters"
tooltip="Admin-owned trace destinations every team in this org exports to (added to each key's and team's). Manage destinations under Settings -> Logging Credentials."
>
<LoggingExportersSelect />
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} />
</Form.Item>
@ -491,6 +521,21 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
</div>
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
</div>
<div>
<Text className="font-medium">Logging Exporters</Text>
{Array.isArray(orgData.metadata?.logging_exporters) &&
orgData.metadata.logging_exporters.length > 0 ? (
<div className="flex flex-wrap gap-2 mt-1">
{orgData.metadata.logging_exporters.map((name: string, index: number) => (
<Badge key={index} color="blue">
{name}
</Badge>
))}
</div>
) : (
<div className="text-gray-400 mt-1">None</div>
)}
</div>
<ObjectPermissionsView
objectPermission={orgData.object_permission}

View file

@ -31,6 +31,7 @@ import AlertingSettings from "./alerting/alerting_settings";
import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import {
credentialDeleteCall,
deleteCallback,
getCallbackConfigsCall,
getCallbacksCall,
@ -38,7 +39,17 @@ import {
setCallbacksCall,
} from "./networking";
import { LoggingCallbacksTable } from "./Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable";
import { AlertingObject } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { AlertingObject, CredentialAccess } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import EditLoggingCredentialModal from "./logging_credentials/EditLoggingCredentialModal";
import AccessControlFields from "./logging_credentials/AccessControlFields";
import {
backendLabel,
createLoggingCredential,
LOGGING_BACKEND_IDS,
NON_CALLBACK_LOGGING_IDS,
} from "./logging_credentials/loggingCredentialApi";
import { LOGGING_DESTINATION_BACKENDS } from "./logging_credentials/loggingDestinationFields";
import { parseErrorMessage } from "./shared/errorUtils";
interface SettingsPageProps {
accessToken: string | null;
@ -247,6 +258,39 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
const [isAddingCallback, setIsAddingCallback] = useState(false);
const [isDeletingCallback, setIsDeletingCallback] = useState(false);
// OTEL trace destinations are credentials tagged credential_type=logging; they share
// the one Active Logging Callbacks table as rows alongside config callbacks.
const { data: credentialData, refetch: refetchCredentials } = useCredentials();
const [editAccessFor, setEditAccessFor] = useState<{ name: string; access?: CredentialAccess } | null>(null);
// access for the destination branch of the unified Add modal
const [addAccess, setAddAccess] = useState<CredentialAccess>({});
const addingDestination = selectedCallback != null && LOGGING_BACKEND_IDS.has(selectedCallback);
const addingDestinationFields =
LOGGING_DESTINATION_BACKENDS.find((b) => b.id === selectedCallback)?.fields ?? [];
const destinationRows: AlertingObject[] = (credentialData?.credentials ?? [])
.filter((c) => c.credential_info?.credential_type === "logging")
.map((c) => ({
name: c.credential_name,
variables: {} as AlertingObject["variables"],
credentialName: c.credential_name,
destinationLabel: c.credential_info?.host
? `${backendLabel(c.credential_info?.description)} · ${c.credential_info.host}`
: backendLabel(c.credential_info?.description),
access: c.credential_info?.access,
}));
const handleDeleteDestination = async (name: string) => {
if (!accessToken) return;
try {
await credentialDeleteCall(accessToken, name);
NotificationsManager.success("Logging destination deleted");
refetchCredentials();
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
};
useEffect(() => {
if (!accessToken) {
return;
@ -379,6 +423,33 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
if (!new_callback) {
return;
}
if (LOGGING_BACKEND_IDS.has(new_callback) && accessToken) {
const backendDef = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === new_callback);
const fields = backendDef?.fields ?? [];
const values = Object.fromEntries(
fields.filter((f) => formValues[f.name]).map((f) => [f.name, formValues[f.name]]),
);
const host = backendDef ? formValues[backendDef.hostField] : undefined;
const hasAccess = addAccess.global || addAccess.teams?.length || addAccess.orgs?.length;
try {
await createLoggingCredential(accessToken, {
credentialName: formValues.credential_name,
backend: new_callback,
values,
host,
access: hasAccess ? addAccess : undefined,
});
NotificationsManager.success("Logging destination created");
refetchCredentials();
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setAddAccess({});
addForm.resetFields();
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
return;
}
await handleCallbackSubmit(formValues, new_callback, false);
};
@ -577,14 +648,19 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
<TabPanels>
<TabPanel>
<LoggingCallbacksTable
callbacks={callbacks}
callbacks={[...callbacks.filter((c) => !NON_CALLBACK_LOGGING_IDS.has(c.name)), ...destinationRows]}
availableCallbacks={allCallbacks}
onAdd={() => setShowAddCallbacksModal(true)}
onEdit={(cb) => {
setSelectedEditCallback(cb);
setShowEditCallback(true);
}}
onDelete={(cb) => handleDeleteCallback(cb)}
onEditAccess={(cb) =>
cb.credentialName && setEditAccessFor({ name: cb.credentialName, access: cb.access })
}
onDelete={(cb) =>
cb.credentialName ? handleDeleteDestination(cb.credentialName) : handleDeleteCallback(cb)
}
onTest={async (cb) => {
try {
await serviceHealthCheck(accessToken, cb.name);
@ -594,6 +670,16 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
}
}}
/>
{accessToken && (
<EditLoggingCredentialModal
accessToken={accessToken}
credentialName={editAccessFor?.name ?? null}
access={editAccessFor?.access}
open={editAccessFor != null}
onClose={() => setEditAccessFor(null)}
onSaved={() => refetchCredentials()}
/>
)}
</TabPanel>
<TabPanel>
<div className="p-8">
@ -702,6 +788,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
setAddAccess({});
}}
footer={null}
>
@ -723,16 +810,42 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
labelAlign="left"
>
<CallbackSelector
callbackConfigs={callbackConfigs}
callbackConfigs={[
...callbackConfigs.filter((c: { id: string }) => !NON_CALLBACK_LOGGING_IDS.has(c.id)),
...LOGGING_DESTINATION_BACKENDS.map((b) => ({ id: b.id, displayName: b.label, logo: "" })),
]}
selectedCallback={selectedCallback}
onCallbackChange={handleSelectedCallbackChange}
/>
<DynamicParamsFields
params={selectedCallbackParams}
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
/>
{addingDestination ? (
<div className="space-y-4 mt-6 p-4 bg-gray-50 rounded-lg border">
<FormItem
label={<span className="text-sm font-medium text-gray-700">Name</span>}
name="credential_name"
rules={[{ required: true, message: "Please enter a name" }]}
>
<Input size="large" placeholder="e.g. langfuse-eu" />
</FormItem>
{addingDestinationFields.map((f) => (
<FormItem
key={f.name}
label={<span className="text-sm font-medium text-gray-700">{f.label}</span>}
name={f.name}
rules={f.optional ? undefined : [{ required: true, message: `Please enter the ${f.label.toLowerCase()}` }]}
>
{f.type === "password" ? <Input.Password size="large" /> : <Input size="large" />}
</FormItem>
))}
<AccessControlFields value={addAccess} onChange={setAddAccess} />
</div>
) : (
<DynamicParamsFields
params={selectedCallbackParams}
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
/>
)}
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button2
@ -740,6 +853,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
setAddAccess({});
addForm.resetFields();
}}
disabled={isAddingCallback}
@ -747,7 +861,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
Cancel
</Button2>
<Button2 htmlType="submit" loading={isAddingCallback} disabled={isAddingCallback}>
{isAddingCallback ? "Adding..." : "Add Callback"}
{isAddingCallback ? "Adding..." : "Add"}
</Button2>
</div>
</Form>

View file

@ -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<TeamInfoProps> = ({
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<TeamInfoProps> = ({
<LoggingSettingsView
loggingConfigs={info.metadata?.logging || []}
loggingExporters={
Array.isArray(info.metadata?.logging_exporters) ? info.metadata.logging_exporters : []
}
disabledCallbacks={[]}
variant="card"
/>
@ -974,6 +981,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
)
: "",
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<TeamInfoProps> = ({
/>
</Form.Item>
<Form.Item
label="Logging Exporters"
name="logging_exporters"
tooltip="Admin-owned trace destinations this team exports to. Resolved server-side and fanned out (added to the key's and org's). Manage destinations under Settings -> Logging Callbacks."
>
<LoggingExportersSelect />
</Form.Item>
<Form.Item label="Logging Settings" name="logging_settings">
<EditLoggingSettings
value={form.getFieldValue("logging_settings")}
@ -1639,6 +1655,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<LoggingSettingsView
loggingConfigs={info.metadata?.logging || []}
loggingExporters={
Array.isArray(info.metadata?.logging_exporters) ? info.metadata.logging_exporters : []
}
disabledCallbacks={[]}
variant="inline"
className="pt-4 border-t border-gray-200"

View file

@ -26,6 +26,7 @@ import { fetchTeamModels } from "../organisms/create_key_button";
import NumericalInput from "../shared/numerical_input";
import { Tag } from "../tag_management/types";
import EditLoggingSettings from "../team/EditLoggingSettings";
import LoggingExportersSelect from "../logging_credentials/LoggingExportersSelect";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
interface KeyEditViewProps {
@ -189,6 +190,7 @@ export function KeyEditView({
accessGroups: keyData.object_permission?.agent_access_groups || [],
},
logging_settings: extractLoggingSettings(keyData.metadata),
logging_exporters: keyData.metadata?.logging_exporters || [],
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
: [],
@ -218,6 +220,7 @@ export function KeyEditView({
},
mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {},
logging_settings: extractLoggingSettings(keyData.metadata),
logging_exporters: keyData.metadata?.logging_exporters || [],
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
: [],
@ -719,6 +722,14 @@ export function KeyEditView({
<Input value={projectDisplay ?? ""} disabled />
</Form.Item>
)}
<Form.Item
label="Logging Exporters"
name="logging_exporters"
tooltip="Admin-owned trace destinations this key exports to. Resolved server-side and fanned out (added to the team's and org's). Manage destinations under Settings -> Logging Credentials."
>
<LoggingExportersSelect />
</Form.Item>
<Form.Item label="Logging Settings" name="logging_settings">
<EditLoggingSettings
value={form.getFieldValue("logging_settings")}

View file

@ -254,6 +254,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),
@ -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({
<LoggingSettingsView
loggingConfigs={extractLoggingSettings(currentKeyData.metadata)}
loggingExporters={
Array.isArray(currentKeyData.metadata?.logging_exporters)
? currentKeyData.metadata.logging_exporters
: []
}
disabledCallbacks={
Array.isArray(currentKeyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(currentKeyData.metadata.litellm_disabled_callbacks)