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

This commit is contained in:
yucheng-berriai 2026-06-29 09:39:16 -07:00
parent 8e30cfbeb1
commit e8020da01e
79 changed files with 7373 additions and 508 deletions

View file

@ -136,6 +136,7 @@ _custom_logger_compatible_callbacks_literal = Literal[
"langfuse",
"langfuse_otel",
"weave_otel",
"generic",
"pagerduty",
"humanloop",
"azure_sentinel",

View file

@ -424,6 +424,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
EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE = float(
os.getenv("EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE", 0.8)

View file

@ -3029,6 +3029,10 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
if signal_type == "traces" and "/v2/trace/otlp" in endpoint:
return endpoint
# Langtrace ingests traces at /api/trace (a complete path, not an OTLP base). Do not rewrite.
if signal_type == "traces" and endpoint.endswith("/api/trace"):
return endpoint
# Check if endpoint already ends with the correct signal path
target_path = f"/v1/{signal_type}"
if endpoint.endswith(target_path):

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

@ -138,6 +138,43 @@ class SpanEmitter:
self.finish_span(role, span, data, end_time_ns=end_time_ns)
return span
def emit_fanout(
self,
role: SpanRole,
data: SpanData,
parent_context: Context | None = None,
*,
start_time_ns: int | None = None,
end_time_ns: int | None = None,
tracers: Sequence[Tracer],
) -> Span | None:
"""Emit one logical span once per tracer, deduping the call ONCE.
A backend that selects its target from the Resource (Arize's project) needs a
separately-tagged span per Resource group, so ``tracers`` is one tracer per group
(from ``TenantTracerCache.tracers_for``). Dedup runs once on the call id so a
sync+async double-firing still coalesces, then a span is started and finished on
each tracer (each provider stamps its own Resource). Returns the first span (or
``None`` if deduped/empty).
"""
dedup_key = data.identity.call_id if isinstance(data, (LLMCallSpanData, MCPToolCallSpanData)) else None
if self._seen(dedup_key, role):
return None
name = _NAME_BUILDERS[role](data)
first: Span | None = None
for tracer in tracers:
span = self.start_span(
role,
name,
parent_context=parent_context,
start_time_ns=start_time_ns,
tracer=tracer,
)
self.finish_span(role, span, data, end_time_ns=end_time_ns)
if first is None:
first = span
return first
def finish_span(
self,
role: SpanRole,

View file

@ -52,6 +52,7 @@ from litellm.integrations.otel.model.spans import SpanRole, span_role_for_servic
from litellm.integrations.otel.model.utils import to_ns
if TYPE_CHECKING:
from litellm.integrations.otel.model.destination import OtelDestination
from litellm.types.utils import (
StandardLoggingGuardrailInformation,
StandardLoggingPayload,
@ -78,18 +79,19 @@ _OPEN_CALLS_MAX = 10_000
class _LLMCallSpan:
"""The state carried from the ``pre_call`` boundary to span close.
``span`` is the live span when it could be opened at the boundary (the server
span was ambient), or ``None`` when creation was deferred because no ambient
parent was visible in which case the async callback creates it against its
own (worker-copied) ambient context using ``start_time_ns``. The presence of
a carrier for a call at all is the proof that ``pre_call`` ran, i.e. that an
upstream call was actually attempted.
``spans`` is one live span per destination Resource group (a backend like Arize
routing two projects yields two), opened at the boundary when the server span was
ambient. It is empty when creation was deferred because no ambient parent was visible
in which case the async callback creates the span(s) against its own
(worker-copied) ambient context using ``start_time_ns``. The presence of a carrier
for a call at all is the proof that ``pre_call`` ran, i.e. that an upstream call was
actually attempted.
"""
__slots__ = ("span", "start_time_ns")
__slots__ = ("spans", "start_time_ns")
def __init__(self, span: "Span | None", start_time_ns: int | None) -> None:
self.span = span
def __init__(self, spans: "tuple[Span, ...]", start_time_ns: int | None) -> None:
self.spans = spans
self.start_time_ns = start_time_ns
@ -109,7 +111,13 @@ class OpenTelemetryV2(CustomLogger):
self.config: OpenTelemetryV2Config = config or OpenTelemetryV2Config(**kwargs)
self.callback_name = callback_name
self._tracer_provider: TracerProvider = (
tracer_provider if tracer_provider is not None else build_tracer_provider(self.config)
tracer_provider
if tracer_provider is not None
else build_tracer_provider(
self.config,
tenant_fan_out_owner=callback_name,
attach_tenant_fan_out=True,
)
)
self.tracer: Tracer = get_tracer(self._tracer_provider, LITELLM_TRACER_NAME)
self._metrics_recorder = self._init_metrics(meter_provider)
@ -117,6 +125,11 @@ class OpenTelemetryV2(CustomLogger):
self._emitter = SpanEmitter(self.tracer, self.config, mappers=resolve_mappers(self.config.mapper_names))
self._tenant_tracers = TenantTracerCache(self.config, callback_name, LITELLM_TRACER_NAME)
self._open_llm_calls: "OrderedDict[str, _LLMCallSpan]" = OrderedDict()
# call_ids for which the LLM-call span has already been emitted; lets
# _close_llm_call no-op on duplicate callbacks (success + failure both
# firing, or success firing twice) instead of double-exporting the
# deferred-emit span. Bounded LRU, same size as _open_llm_calls.
self._closed_call_ids: "OrderedDict[str, None]" = OrderedDict()
self._init_otel_logger_on_litellm_proxy()
def _init_metrics(self, meter_provider: Any | None) -> "GenAIMetricRecorder | None":
@ -159,6 +172,15 @@ 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``.
@ -194,21 +216,27 @@ class OpenTelemetryV2(CustomLogger):
if call_id in self._open_llm_calls:
return
start_time_ns = to_ns(datetime.now())
span: Span | None = None
# One live span per destination Resource group (Arize routing two projects
# yields two; header-routed backends yield one). Empty until a recordable
# parent is confirmed.
spans: tuple[Span, ...] = ()
# Parent to the request's anchored root span (stable across the request),
# falling back to ambient on the SDK path. Open the span live only when
# that resolves to a recordable parent; otherwise defer to the close
# callback (the thread-pool case, where the anchor isn't visible here).
parent_context = resolve_request_span_context()
if is_recordable_span(get_current_span(parent_context)):
span = self._emitter.start_span(
SpanRole.LLM_CALL,
call.provisional_span_name,
parent_context=parent_context,
start_time_ns=start_time_ns,
tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params),
spans = tuple(
self._emitter.start_span(
SpanRole.LLM_CALL,
call.provisional_span_name,
parent_context=parent_context,
start_time_ns=start_time_ns,
tracer=tracer,
)
for tracer in self._tenant_tracers.tracers_for(self.tracer, self._destinations_for_backend(call))
)
self._open_llm_calls[call_id] = _LLMCallSpan(span=span, start_time_ns=start_time_ns)
self._open_llm_calls[call_id] = _LLMCallSpan(spans=spans, start_time_ns=start_time_ns)
# Evict the oldest open call if the map is over budget. A call that opens
# but never closes (a stream that only fires stream events) would linger
# otherwise; the evicted span is simply dropped (never exported).
@ -288,38 +316,85 @@ class OpenTelemetryV2(CustomLogger):
) -> Span | None:
"""Finish the LLM-call span opened at ``pre_call`` (or create it deferred).
No carrier for this call id means ``pre_call`` never ran the request was
rejected at the gate or blocked by a pre-call guardrail before any upstream
call so there is nothing to record and no phantom span.
Missing carrier has two shapes. ``pre_call`` genuinely never ran -- the
request was rejected at the gate or blocked by a pre-call guardrail before
any upstream call, so no payload exists and dropping is correct. OR this
v2 instance was lazily activated AFTER ``pre_call`` iterated the callback
list (the destination-resolver path: a credential resolved a backend the
YAML didn't pre-list), so the upstream call DID happen, the payload IS
set, and the admin-resolved destinations name this backend -- emit a
deferred span with the success event's start time so the per-tenant
exporter ships it.
"""
call = LLMCallEvent.from_dict(kwargs)
call_id = call.call_id
# ``pop`` is the dedup: this method runs from both the success and failure
# paths, and whichever fires first removes the carrier and closes the span.
# Dedup guard: a normal close pops the carrier and emits a span. If a
# second close fires for the same call_id (success + failure callbacks
# are wired separately, custom callbacks can fan out, etc.), the
# carrier is already gone and a payload+destinations combination would
# otherwise emit a second deferred span.
if call_id and call_id in self._closed_call_ids:
return None
carrier = self._open_llm_calls.pop(call_id, None) if call_id else None
if carrier is None:
return None
payload = call.payload
if payload is None:
if carrier.span is not None:
# Opened at the boundary but the payload never materialized — end
# it (named provisionally) so it isn't leaked as an open span.
carrier.span.end(end_time=to_ns(end_time))
return None
data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=self.config.capture_span_content)
if carrier is None:
destinations = self._destinations_for_backend(call)
if payload is None or not destinations:
return None
self._mark_closed(call_id)
return self._emit_deferred_llm_call(payload, destinations, to_ns(start_time), to_ns(end_time))
end_time_ns = to_ns(end_time)
if carrier.span is not None:
# Born at the boundary: stamp attributes from the typed payload, set
# status, and end it. Its parent (the server span) was captured at
# creation from real ambient context.
self._emitter.finish_span(SpanRole.LLM_CALL, carrier.span, data, end_time_ns=end_time_ns)
return carrier.span
# Deferred: ``pre_call`` saw no recordable parent, so create the span now.
# The worker copied the request task's context, which carries the anchored
# root span — parent to it (ambient fallback on the SDK path). Seed identity
# Baggage so the span — and the SDK path, which has none — is labeled
# consistently.
parent_ctx = resolve_request_span_context()
self._mark_closed(call_id)
if payload is None:
for span in carrier.spans:
span.end(end_time=end_time_ns)
return None
data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=self.config.capture_span_content)
if carrier.spans:
for span in carrier.spans:
self._emitter.finish_span(SpanRole.LLM_CALL, span, data, end_time_ns=end_time_ns)
return carrier.spans[0]
return self._emit_deferred_llm_call(
payload,
self._destinations_for_backend(call),
carrier.start_time_ns,
end_time_ns,
)
def _mark_closed(self, call_id: str | None) -> None:
"""Remember a call_id has been closed so a duplicate callback no-ops.
Bounded by the same ceiling as ``_open_llm_calls`` to prevent unbounded
growth; oldest entries are evicted FIFO.
"""
if not call_id:
return
self._closed_call_ids[call_id] = None
if len(self._closed_call_ids) > _OPEN_CALLS_MAX:
self._closed_call_ids.popitem(last=False)
def _emit_deferred_llm_call(
self,
payload: "StandardLoggingPayload",
destinations: "tuple[OtelDestination, ...]",
start_time_ns: int | None,
end_time_ns: int | None,
) -> Span | None:
"""Emit an LLM-call span outside the ``pre_call`` boundary.
Two callers: the SDK thread-pool path (carrier existed but ``pre_call``
saw no recordable parent) and the destination-resolver path (this v2
instance was born after ``pre_call`` ran, so no carrier was ever opened).
Both anchor to the request's root span via the worker-copied context and
seed identity Baggage so the span is labeled consistently.
"""
data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=self.config.capture_span_content)
base_ctx = resolve_request_span_context()
bag = promoted_baggage(
data.identity,
data.request_model,
@ -327,15 +402,14 @@ class OpenTelemetryV2(CustomLogger):
metadata_keys=tuple(self.config.baggage_metadata_keys),
team_metadata_keys=tuple(self.config.baggage_team_metadata_keys),
)
if bag:
parent_ctx = set_request_baggage(bag, context=parent_ctx)
return self._emitter.emit(
parent_ctx = set_request_baggage(bag, context=base_ctx) if bag else base_ctx
return self._emitter.emit_fanout(
SpanRole.LLM_CALL,
data,
parent_context=parent_ctx,
start_time_ns=carrier.start_time_ns,
start_time_ns=start_time_ns,
end_time_ns=end_time_ns,
tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params),
tracers=self._tenant_tracers.tracers_for(self.tracer, destinations),
)
# ====================================================================== #

View file

@ -0,0 +1,27 @@
"""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)
resource_attributes: 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
@ -194,6 +222,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
@ -208,10 +240,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

@ -101,17 +101,27 @@ def instrument_fastapi_app(app: Any) -> None:
after config load (see ``proxy_startup_event``), and the proxy delegates to it.
That way server spans and gen-ai spans share one provider and the same trace.
"""
if not is_otel_v2_enabled():
return
# Lazy: only the V2-enabled path needs the optional
# ``opentelemetry-instrumentation-fastapi`` package. Importing it at module top
# would make ``proxy_server``'s unconditional ``import`` of this module crash when
# the package is absent, even with the gate off. When V2 IS on, a missing package
# is a real misconfiguration -- without the server span the trace has no root and
# admin-owned destination traces are orphaned -- so it must be loud, not silent.
try:
if not is_otel_v2_enabled():
return
# Lazy: only the V2-enabled path needs the optional
# ``opentelemetry-instrumentation-fastapi`` package, which is not part of the
# base ``litellm[proxy]`` install. Importing it at module top would make
# ``proxy_server``'s unconditional ``import`` of this module crash when the
# package is absent, even with the gate off.
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
except ImportError:
verbose_logger.warning(
"LITELLM_OTEL_V2 is enabled but 'opentelemetry-instrumentation-fastapi' "
"is not installed. The FastAPI server span will not be created, so traces "
"exported to admin-owned destinations will be missing their root span "
"(orphaned children). Install 'opentelemetry-instrumentation-fastapi'."
)
return
try:
excluded_urls = (
os.environ.get("OTEL_PYTHON_FASTAPI_EXCLUDED_URLS")
if "OTEL_PYTHON_FASTAPI_EXCLUDED_URLS" in os.environ
@ -127,4 +137,4 @@ def instrument_fastapi_app(app: Any) -> None:
exclude_spans=["receive", "send"],
)
except Exception as e:
verbose_logger.debug("Skipping OTel V2 FastAPI instrumentation: %s", e)
verbose_logger.warning("OTel V2 FastAPI instrumentation failed: %s", e)

View file

@ -1,7 +1,7 @@
"""Trace-context + Baggage helpers."""
from contextvars import ContextVar
from typing import Mapping
from typing import TYPE_CHECKING, Mapping
from opentelemetry import baggage
from opentelemetry.context import Context, get_current
@ -10,6 +10,9 @@ from opentelemetry.trace.propagation.tracecontext import (
TraceContextTextMapPropagator,
)
if TYPE_CHECKING:
from litellm.integrations.otel.model.destination import OtelDestination
_PROPAGATOR = TraceContextTextMapPropagator()
# The request's root span — the FastAPI-owned SERVER span — captured ONCE when the
@ -29,6 +32,31 @@ _PROPAGATOR = TraceContextTextMapPropagator()
# request task, so there is nothing to leak.
_request_root_span: "ContextVar[Span | None]" = ContextVar("litellm_otel_request_root_span", default=None)
# Per-request admin-resolved destinations. Set once at the auth boundary (the
# earliest point a request's identity is known) and read by the global-provider
# fan-out processor at ``on_end`` time, so every span the proxy emits for this
# request -- the FastAPI server span, the ``auth`` phase, DB lookups, the
# batch-write cost ledger -- ships to every per-tenant destination the admin
# assigned. Lives on a ``ContextVar`` so it follows the request task across
# ``asyncio.create_task`` children (the success/failure logging callbacks close
# the LLM span in a worker copied from the request context). Request-scoped: the
# contextvar dies with the request task, so nothing leaks across requests.
_request_destinations: 'ContextVar[tuple["OtelDestination", ...]]' = ContextVar(
"litellm_otel_request_destinations", default=()
)
def set_request_destinations(
destinations: 'tuple["OtelDestination", ...]',
) -> None:
"""Anchor the admin-resolved destinations for this request."""
_request_destinations.set(tuple(destinations))
def request_destinations() -> 'tuple["OtelDestination", ...]':
"""Destinations the request fans out to, or empty when none were resolved."""
return _request_destinations.get()
def set_request_root_span(span: Span) -> None:
"""Anchor the request's root (server) span for explicit child parenting.

View file

@ -0,0 +1,204 @@
"""Per-request span fan-out to admin-resolved destinations.
Attached to the main ``TracerProvider`` so every span on it (the FastAPI server
span, the proxy's ``auth`` phase span, DB lookups, the post-call cost ledger)
ships to every per-tenant destination the admin assigned for this request, on
top of the provider's configured global exporters.
Spans emitted through the per-tenant ``TenantTracerCache`` clone providers (the
gen-AI LLM-call span and its MCP-tool sibling) reach tenant backends through
the clone's own exporters; the clone's provider has its own processor list and
does NOT carry this fan-out processor, so a span is exported once per backend.
Each backend often requires backend-specific Resource attributes (Arize rejects
spans missing ``model_id`` / ``arize.project.name``), so the fan-out wraps each
forwarded span with the destination's expected Resource before handing it to
the per-destination exporter.
"""
from __future__ import annotations
from collections import OrderedDict
from typing import TYPE_CHECKING
from opentelemetry.context import Context
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
from litellm._logging import verbose_logger
from litellm.integrations.otel.model.config import ExporterSpec
from litellm.integrations.otel.plumbing.context import request_destinations
if TYPE_CHECKING:
from litellm.integrations.otel.model.destination import OtelDestination
# Bound on cached per-destination processors. One processor per
# ``(endpoint, sorted(headers))`` pair, so the working set is one entry per
# admin-resolved tenant credential -- a real-world deployment with hundreds of
# tenants stays well under this. Evicted entries are dropped (not shut down; see
# the eviction site) and reclaimed at process exit.
_MAX_CACHED_PROCESSORS = 256
def _processor_key(destination: OtelDestination) -> tuple:
return (destination.endpoint, tuple(sorted(destination.headers.items())))
class TenantFanOutSpanProcessor(SpanProcessor):
"""Forward each finished span to every admin-resolved per-tenant destination.
The destinations are looked up from a request-scoped contextvar set during
auth, so the processor is stateless across requests and isolation across
concurrent requests is guaranteed by Python's contextvars.
"""
def __init__(self, owner_callback_name: str | None) -> None:
self._owner = owner_callback_name
# Built lazily so we avoid importing the providers module at class
# definition time (which would create a circular import with routing).
self._processors: OrderedDict[tuple, SpanProcessor] = OrderedDict()
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
return None
def on_end(self, span: ReadableSpan) -> None:
destinations = request_destinations()
if not destinations:
return
# The gen-AI LLM-call span (and the MCP tool-call sibling) is already
# routed to per-tenant destinations by the per-backend v2 logger via
# ``TenantTracerCache`` -- the logger picks the right attribute mapper
# (OpenInference for arize, GenAI semconv for langfuse_otel) and ships
# through the clone provider's appended exporter. Forwarding it here too
# would deliver a SECOND copy with the wrong vocabulary and a fresh
# span_id, surfacing in the destination as an orphaned duplicate. Skip.
if _is_genai_span(span):
return
# Proxy-internal spans (FastAPI server, ``auth`` phase, postgres lookups,
# post-call cost ledger) are generic OTel semantic-convention spans with
# no backend-specific vocabulary, so they ship to EVERY admin-resolved
# destination this request fans out to, regardless of the destination's
# ``callback_name``. The owner discriminator only matters for the gen-AI
# span (handled by the skip above).
for destination in destinations:
processor = self._processor_for(destination)
if processor is None:
continue
try:
processor.on_end(_with_destination_resource(span, destination))
except Exception as exc: # noqa: BLE001
verbose_logger.debug(
"OTel V2 fan-out: forwarding span to %s failed: %s",
destination.endpoint,
exc,
)
def shutdown(self) -> None:
for processor in self._processors.values():
try:
processor.shutdown()
except Exception as exc: # noqa: BLE001
verbose_logger.debug("OTel V2 fan-out: processor shutdown failed: %s", exc)
self._processors.clear()
def force_flush(self, timeout_millis: int = 30000) -> bool:
all_ok = True
for processor in self._processors.values():
try:
if not processor.force_flush(timeout_millis):
all_ok = False
except Exception: # noqa: BLE001
all_ok = False
return all_ok
def _processor_for(self, destination: OtelDestination) -> SpanProcessor | None:
key = _processor_key(destination)
cached = self._processors.get(key)
if cached is not None:
self._processors.move_to_end(key)
return cached
from litellm.integrations.otel.plumbing.providers import (
_exporter_from_spec,
_processor_for as _build_processor,
default_otlp_kind_for_backend,
)
try:
spec = ExporterSpec(
kind=default_otlp_kind_for_backend(destination.callback_name),
endpoint=destination.endpoint,
headers=destination.header_string(),
owner=None,
)
exporter = _exporter_from_spec(spec)
processor = _build_processor(exporter, use_simple=False)
except Exception as exc:
verbose_logger.debug(
"OTel V2 fan-out: failed to build processor for %s: %s",
destination.endpoint,
exc,
)
return None
self._processors[key] = processor
if len(self._processors) > _MAX_CACHED_PROCESSORS:
# Evict the LRU entry but do NOT shut it down here: a
# ``BatchSpanProcessor`` may still hold spans queued on its exporter
# thread, and calling ``shutdown`` synchronously can drop or raise on
# those in-flight spans. Dropping the reference lets the worker drain
# naturally and be reclaimed at process exit. The cache is bounded at
# ``_MAX_CACHED_PROCESSORS``, so the un-shut-down working set stays
# bounded.
self._processors.popitem(last=False)
return processor
# Attribute set on every gen-AI LLM-call span by the v2 emitter. Used as the
# unambiguous skip signal: only the LLM-call span carries this, and the
# per-backend v2 logger already routes it to per-tenant destinations through
# the TenantTracerCache clone provider's appended exporter.
_GENAI_SPAN_ATTR = "gen_ai.operation.name"
def _is_genai_span(span: ReadableSpan) -> bool:
attributes = span.attributes or {}
return _GENAI_SPAN_ATTR in attributes
def _with_destination_resource(span: ReadableSpan, destination: OtelDestination) -> ReadableSpan:
"""Return ``span`` with its Resource augmented by the destination's required
attributes. The original span object is left untouched; a shallow wrapper
reuses every other field and only swaps the ``resource`` property."""
from litellm.integrations.otel.plumbing.providers import (
destination_resource_attrs,
)
extra = destination_resource_attrs(destination)
if not extra:
return span
merged = Resource.create({**dict(span.resource.attributes), **extra})
return _ResourceWrappedReadableSpan(span, merged)
class _ResourceWrappedReadableSpan(ReadableSpan):
"""A ``ReadableSpan`` view whose ``resource`` is overridden.
The OTLP exporter reads each span's ``resource`` when serializing; for
backend-specific attributes (Arize's ``model_id``) we substitute the
destination's expected Resource without mutating the underlying span.
"""
def __init__(self, inner: ReadableSpan, resource: Resource) -> None:
super().__init__(
name=inner.name,
context=inner.context,
parent=inner.parent,
resource=resource,
attributes=inner.attributes,
events=inner.events,
links=inner.links,
kind=inner.kind,
status=inner.status,
start_time=inner.start_time,
end_time=inner.end_time,
instrumentation_scope=inner.instrumentation_scope,
)

View file

@ -31,6 +31,8 @@ if TYPE_CHECKING:
from opentelemetry.metrics import Meter
from opentelemetry.sdk.metrics.export import MetricReader
from litellm.integrations.otel.model.destination import OtelDestination
_SPAN_KIND_BY_ROLE_KIND: dict[LiteLLMSpanKind, SpanKind] = {
LiteLLMSpanKind.SERVER: SpanKind.SERVER,
LiteLLMSpanKind.CLIENT: SpanKind.CLIENT,
@ -99,8 +101,11 @@ def _otlp_traces_endpoint(endpoint: str | None) -> str | None:
if not endpoint:
return endpoint
endpoint = endpoint.rstrip("/")
# Splunk Observability uses ``/v2/trace/otlp``; never rewrite it.
if endpoint.endswith("/v1/traces") or "/v2/trace/otlp" in endpoint:
# Some vendors expose a complete traces ingest path that is NOT the OTLP-standard
# ``/v1/traces`` base: Splunk Observability uses ``/v2/trace/otlp`` and Langtrace
# ingests at ``/api/trace``. Appending ``/v1/traces`` to those 404s, so never
# rewrite them.
if endpoint.endswith("/v1/traces") or "/v2/trace/otlp" in endpoint or endpoint.endswith("/api/trace"):
return endpoint
for other_signal in ("/v1/logs", "/v1/metrics"):
if endpoint.endswith(other_signal):
@ -108,6 +113,37 @@ def _otlp_traces_endpoint(endpoint: str | None) -> str | None:
return endpoint + "/v1/traces"
# Backends whose OTLP transport is gRPC. Arize's OTLP endpoint
# (``otlp.arize.com``) speaks gRPC; every other current preset speaks OTLP/HTTP.
# Single source of truth shared by the per-tenant fan-out processor and the
# ``TenantTracerCache`` so the two never disagree on a destination's transport.
_GRPC_BACKENDS = frozenset({"arize"})
def default_otlp_kind_for_backend(callback_name: "str | None") -> str:
"""The intrinsic OTLP transport for a backend's own OTLP endpoint."""
return "otlp_grpc" if callback_name in _GRPC_BACKENDS else "otlp_http"
def destination_resource_attrs(destination: "OtelDestination") -> dict[str, str]:
"""The backend-required Resource attributes a destination carries on every span.
Backend-agnostic: each backend's destination builder (``presets.destinations``)
declares whatever Resource attributes its ingestion needs, and this just reads
them. Backends that route by auth header (langfuse, weave, generic OTLP) declare
none; Arize declares ``model_id`` / ``arize.project.name`` because it selects the
project from the Resource, not a header. New backends needing Resource-level
routing only have to populate ``resource_attributes`` in their builder.
Shared by the two export paths that reach a per-tenant destination -- the
``TenantFanOutSpanProcessor`` (proxy-internal spans) and the ``TenantTracerCache``
clone provider (the gen-AI span) -- so the gen-AI span and its parents always
carry the SAME Resource and a backend like Arize renders one connected trace
instead of an orphaned subtree.
"""
return dict(destination.resource_attributes)
def _exporter_from_spec(spec: ExporterSpec) -> SpanExporter:
kind = (spec.kind or "console").lower()
factory = _EXPORTER_FACTORIES.get(kind)
@ -282,6 +318,8 @@ def build_tracer_provider(
exporter: SpanExporter | None = None,
baggage_processor: SpanProcessor | None = None,
use_simple_processor: bool | None = None,
tenant_fan_out_owner: str | None = None,
attach_tenant_fan_out: bool = False,
) -> TracerProvider:
"""Build the shared :class:`TracerProvider`.
@ -290,12 +328,30 @@ def build_tracer_provider(
``config.exporters`` entry this is what fans spans out to multiple
backends. ``exporter`` and ``use_simple_processor`` are explicit overrides:
pass a single exporter to attach exactly that one (used by tests).
``attach_tenant_fan_out`` attach a ``TenantFanOutSpanProcessor`` that forwards
each finished proxy-internal span (FastAPI server, ``auth`` phase, DB lookups, the
cost ledger) to the request's admin-resolved destinations. The MAIN v2 logger
provider always opts in, EVEN when no backend is named (the generic global logger
published for a destination-only deployment) -- otherwise the server span never
reaches the destination and its gen-AI child is orphaned. ``tenant_fan_out_owner``
is the owning backend name when one exists; it is informational (the fan-out skips
the gen-AI span by attribute and forwards internal spans to every destination).
The per-tenant clone providers (built by ``TenantTracerCache``) pass neither, so
the LLM-call span exported through them is not also fanned out here.
"""
provider = TracerProvider(resource=build_resource(config))
if baggage_processor is None:
baggage_processor = LiteLLMBaggageSpanProcessor(allowed_keys=config.baggage_promoted_keys)
provider.add_span_processor(baggage_processor)
if attach_tenant_fan_out or tenant_fan_out_owner is not None:
from litellm.integrations.otel.plumbing.fan_out import (
TenantFanOutSpanProcessor,
)
provider.add_span_processor(TenantFanOutSpanProcessor(owner_callback_name=tenant_fan_out_owner))
if exporter is not None:
provider.add_span_processor(_processor_for(exporter, use_simple_processor))
return provider

View file

@ -1,36 +1,41 @@
"""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 ``TracerProvider``s that append one
``SpanProcessor`` per destination. The gen-AI span path (``tracers_for``) groups
destinations by their backend-required Resource attributes and builds one provider
per group, because a span carries exactly one Resource (a provider property) and a
backend like Arize selects its project FROM the Resource -- so two Arize projects
each get a correctly-tagged span instead of one last-wins merge. Header-routed
backends declare no Resource attributes, so their destinations stay in one group with
multiple exporters and route by per-exporter auth. 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 +54,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,49 +65,162 @@ 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[object, ...], TracerProvider] = OrderedDict()
def tracer_for(self, default: Tracer, dynamic_params: Any) -> Tracer:
"""Return the tracer for this request.
def tracers_for(self, default: Tracer, destinations: "tuple[OtelDestination, ...]") -> "tuple[Tracer, ...]":
"""The tracers for this request's gen-AI span, one per distinct Resource group.
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.
A span carries exactly one Resource (it's a property of the ``TracerProvider``),
but a backend like Arize selects its project FROM the Resource
(``arize.project.name`` / ``model_id``), so two Arize destinations with different
projects need two differently-tagged spans. Group the backend's resolved
destinations by ``destination_resource_attrs`` and return one tracer per group;
the caller emits the span once per tracer (mirroring how the fan-out processor
re-wraps proxy-internal spans per destination).
Header-routed backends (langfuse, weave) declare no Resource attributes, so all
their destinations collapse into one empty-Resource group with one exporter each
and keep routing by per-exporter auth -- unchanged from the single-group path.
The configured/global exporters ride the FIRST group only, so the global receives
the span once. Empty ``destinations`` -> the logger's default tracer (deny).
"""
headers = dynamic_otlp_headers(self._callback_name, dynamic_params)
if not headers:
return default
cache_key = tuple(sorted(headers.items()))
if not destinations:
return (default,)
return tuple(
self._tracer_for_group(resource_key, group, include_base=index == 0)
for index, (resource_key, group) in enumerate(self._group_by_resource(destinations))
)
def _group_by_resource(
self, destinations: "tuple[OtelDestination, ...]"
) -> "list[tuple[tuple[tuple[str, str], ...], list[OtelDestination]]]":
"""Destinations grouped by their backend-required Resource attributes.
The key is a stable sorted tuple of ``destination_resource_attrs`` items.
Groups are returned in a deterministic order (sorted by key), so the
empty-Resource group (header-routed backends) sorts first and the
configured/global exporters attach to it.
"""
from litellm.integrations.otel.plumbing.providers import (
destination_resource_attrs,
)
groups: OrderedDict[tuple[tuple[str, str], ...], list[OtelDestination]] = OrderedDict()
for destination in destinations:
key = tuple(sorted(destination_resource_attrs(destination).items()))
groups.setdefault(key, []).append(destination)
return sorted(groups.items())
def _tracer_for_group(
self,
resource_key: "tuple[tuple[str, str], ...]",
group: "list[OtelDestination]",
*,
include_base: bool,
) -> Tracer:
cache_key: tuple[object, ...] = (
resource_key,
tuple(sorted((d.endpoint, tuple(sorted(d.headers.items()))) for d in group)),
include_base,
)
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(tuple(group), include_base_exporters=include_base)
)
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.
def tracer_for(self, default: Tracer, destinations: "tuple[OtelDestination, ...]") -> Tracer:
"""Single merged tracer for ``destinations`` (one provider, one Resource).
``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.
The single-group primitive: kept for the destination-set cache mechanics and as
the building block ``tracers_for`` composes per group. The gen-AI span path uses
``tracers_for`` so multiple Resource groups aren't last-wins merged.
"""
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 and spec.kind.lower() not in _NON_OTLP_KINDS
else spec
if not destinations:
return default
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_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 _owned_otlp_kind(self) -> str:
"""The OTLP transport of this integration's own exporter (langfuse -> http,
arize -> grpc), used for the destinations appended below.
Prefer the admin's configured exporter kind for this backend; fall back to
the backend's intrinsic default (shared with the fan-out processor via
``default_otlp_kind_for_backend``) so a lazily-activated backend with no
owned spec still picks the right transport (e.g. arize -> grpc, not http).
"""
from litellm.integrations.otel.plumbing.providers import (
default_otlp_kind_for_backend,
)
for spec in self._config.exporters:
if spec.owner == self._callback_name and spec.kind.lower() not in _NON_OTLP_KINDS:
return spec.kind
return default_otlp_kind_for_backend(self._callback_name)
def _config_with_destinations(
self,
destinations: "tuple[OtelDestination, ...]",
*,
include_base_exporters: bool = True,
) -> OpenTelemetryV2Config:
"""Clone the config and APPEND one exporter per resolved destination. The shared
``TracerProvider`` attaches one ``SpanProcessor`` per spec, so a single span is
emitted once and exported to every appended destination. Each appended exporter's
endpoint is the resolved host (the cross-host fix) with its own auth headers
(per-destination isolation).
``include_base_exporters`` keeps the configured/global exporters too (so the
global still receives). ``tracers_for`` sets it only on the first Resource group,
so when a backend splits into multiple groups the global gets the span once
rather than once per group.
The clone's Resource folds in the destinations' backend-required Resource
attributes (Arize needs ``model_id`` / ``arize.project.name``), via the same
``destination_resource_attrs`` the fan-out path uses on proxy-internal spans.
Callers group destinations by those attributes first, so within one call all
``destinations`` share a Resource and the merge is not lossy -- without this the
gen-AI span would reach Arize with only ``service.name`` while its parents
(fan-out) carry ``model_id``, orphaning the subtree."""
from litellm.integrations.otel.plumbing.providers import (
destination_resource_attrs,
)
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})
base_exporters = [*self._config.exporters] if include_base_exporters else []
merged_resource_attrs = {
**self._config.resource_attributes,
**{key: value for d in destinations for key, value in destination_resource_attrs(d).items()},
}
return self._config.model_copy(
update={
"exporters": [*base_exporters, *appended],
"resource_attributes": merged_resource_attrs,
}
)

View file

@ -6,22 +6,23 @@ 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.presets.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.generic import generic_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.
@ -29,45 +30,20 @@ PRESET_BY_CALLBACK: dict[str, Preset] = {
"agentops": agentops_preset,
"arize": arize_preset,
"arize_phoenix": phoenix_preset,
"generic": generic_preset,
"langfuse_otel": langfuse_preset,
"langtrace": langtrace_preset,
"levo": levo_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",
"generic_preset",
"langfuse_preset",
"langtrace_preset",
"levo_preset",

View file

@ -39,6 +39,7 @@ class _AgentOpsSettings(BaseSettings):
def agentops_preset(
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config:
"""Build the AgentOps config without any network I/O.
@ -49,17 +50,24 @@ def agentops_preset(
"""
settings = _AgentOpsSettings()
base = config_overrides or OpenTelemetryV2Config()
# Contribute the global AgentOps exporter only when an API key is configured.
# Without it the lazy-auth exporter has nothing to mint a JWT from and every
# export fails; admin-owned destinations carry their own credentials.
global_exporter = (
(
ExporterSpec(
kind=_AGENTOPS_EXPORTER_KIND,
endpoint=_AGENTOPS_ENDPOINT,
options={"api_key": settings.api_key},
owner=ExporterOwner.AGENTOPS,
),
)
if settings.api_key
else ()
)
return base.model_copy(
update={
"exporters": [
*base.exporters,
ExporterSpec(
kind=_AGENTOPS_EXPORTER_KIND,
endpoint=_AGENTOPS_ENDPOINT,
options=({"api_key": settings.api_key} if settings.api_key else None),
owner=ExporterOwner.AGENTOPS,
),
],
"exporters": [*base.exporters, *global_exporter],
"resource_attributes": {
**base.resource_attributes,
"service.name": settings.service_name,

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):
@ -24,21 +23,30 @@ class _ArizeSettings(BaseSettings):
def arize_preset(
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config:
arize_cfg = _V1ArizeLogger.get_arize_config()
headers = _arize_headers(arize_cfg)
base = config_overrides or OpenTelemetryV2Config()
# Contribute the global Arize exporter only when Arize credentials are
# configured. Without them it points at the Arize cloud with no auth and every
# export fails PERMISSION_DENIED; admin-owned destinations carry their own
# credentials and are appended by the router instead.
global_exporter = (
(
ExporterSpec(
kind=arize_cfg.protocol or "otlp_grpc",
endpoint=arize_cfg.endpoint or "https://otlp.arize.com/v1",
headers=headers,
owner=ExporterOwner.ARIZE_AX,
),
)
if headers
else ()
)
return base.model_copy(
update={
"exporters": [
*base.exporters,
ExporterSpec(
kind=arize_cfg.protocol or "otlp_grpc",
endpoint=arize_cfg.endpoint or "https://otlp.arize.com/v1",
headers=headers,
owner=ExporterOwner.ARIZE_AX,
),
],
"exporters": [*base.exporters, *global_exporter],
"mapper_names": ensure_mappers(base.mapper_names, "openinference"),
"resource_attributes": {
**base.resource_attributes,
@ -59,16 +67,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

@ -18,6 +18,20 @@ class Preset(Protocol):
``config_overrides`` lets one preset layer onto another's config (or onto
test-supplied defaults); the factory calls presets with no arguments.
``allow_missing_credentials`` distinguishes the two reasons a preset is built.
A credential-mandatory backend (weave/langfuse/levo) raises when its global env
credentials are absent so a misconfigured global callback fails loud at startup;
set this when the only reason for construction is an admin-owned destination,
which carries its own per-tenant credentials, so the preset degrades to an
exporter-less (mapper-only) config instead of raising. Credential-optional
backends (arize/phoenix/agentops/langtrace) already run without a global
exporter and ignore it.
"""
def __call__(self, *, config_overrides: OpenTelemetryV2Config | None = None) -> OpenTelemetryV2Config: ...
def __call__(
self,
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config: ...

View file

@ -0,0 +1,130 @@
"""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.
"""
import os
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"
# Arize routes a trace to a project via the ``model_id`` Resource attribute
# (OpenInference convention), NOT an auth header like langfuse/weave do, so the
# project must ride the span Resource. Prefer the credential's own project, then
# fall back to the proxy-global ``ARIZE_PROJECT_NAME`` so an arize credential
# that omits the project still lands somewhere deterministic. Backends that route
# by header declare no resource_attributes; this stays arize-local.
project = values.get("arize_project_name") or values.get("project_name") or os.environ.get("ARIZE_PROJECT_NAME")
resource_attributes = {"model_id": project, "arize.project.name": project} if project else {}
return OtelDestination(
endpoint=endpoint,
headers={"space_id": space, "api_key": api_key},
resource_attributes=resource_attributes,
)
def _weave_destination(values: Mapping[str, str]) -> Optional[OtelDestination]:
api_key = values.get("wandb_api_key")
if not api_key:
return None
# Weave's OTLP path is ``/otel/v1/traces`` (not the bare ``/v1/traces`` the
# generic exporter would append), so a host like ``https://trace.wandb.ai``
# must be completed here -- otherwise the export 404s and silently drops. The
# host itself defaults to the Weave cloud base (only dedicated/self-hosted wandb
# differs), so the endpoint is optional. Mirror the v1 integration's
# WEAVE_BASE_URL / WEAVE_OTEL_ENDPOINT and stay idempotent if the caller already
# supplied the full path or the ``/otel`` prefix.
from litellm.integrations.weave.weave_otel import (
WEAVE_BASE_URL,
WEAVE_OTEL_ENDPOINT,
)
base = (values.get("weave_endpoint") or WEAVE_BASE_URL).rstrip("/")
endpoint = base if base.endswith("/v1/traces") else base.removesuffix("/otel") + WEAVE_OTEL_ENDPOINT
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.
Values are trimmed first: a stray leading/trailing space in an endpoint or
host (an easy slip in the create form) yields a malformed OTLP URL the
exporter rejects with a 404, so whitespace is never significant here.
"""
trimmed = {key: value.strip() if isinstance(value, str) else value for key, value in values.items()}
adapter = _ADAPTERS.get(callback_name)
if adapter is not None:
destination = adapter(trimmed)
if destination is not None:
return destination
return _generic_destination(trimmed)

View file

@ -0,0 +1,21 @@
"""Generic OTLP passthrough preset.
A ``generic`` admin-owned destination carries only an ``otel_endpoint`` /
``otel_headers`` (no vendor adapter), so this preset is deliberately vendor-neutral:
it yields a base ``OpenTelemetryV2`` config with the standard ``gen_ai.*`` (+ ``legacy``)
span vocabulary, NO vendor mapper, and NO exporter of its own. The per-destination
exporter is appended by ``TenantTracerCache`` from the resolved ``OtelDestination``, so a
generic destination receives the same complete trace tree as Arize/Langfuse/Weave --
root server span, auth/db internal spans, the ``chat <model>`` gen-AI span, and
cost/error spans. It needs no global OTEL env vars and never raises.
"""
from litellm.integrations.otel.model.config import OpenTelemetryV2Config
def generic_preset(
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config:
return config_overrides or OpenTelemetryV2Config()

View file

@ -9,16 +9,27 @@ 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(
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config:
cfg = _V1Langfuse.get_langfuse_otel_config()
kind = cfg.exporter if isinstance(cfg.exporter, str) else "otlp_http"
base = config_overrides or OpenTelemetryV2Config()
mappers = ensure_mappers(base.mapper_names, "langfuse")
# ``get_langfuse_otel_config()`` raises without Langfuse keys. Propagate that raise
# for a global callback so a misconfigured deployment fails loud, but when an
# admin-owned Langfuse destination is the reason for construction it carries its own
# per-tenant keys, so degrade to a (global-exporter-less) mapper-only config -- or
# the gen-AI span falls to the generic logger and never reaches it.
try:
cfg = _V1Langfuse.get_langfuse_otel_config()
except Exception:
if not allow_missing_credentials:
raise
return base.model_copy(update={"mapper_names": mappers})
kind = cfg.exporter if isinstance(cfg.exporter, str) else "otlp_http"
return base.model_copy(
update={
"exporters": [
@ -30,19 +41,6 @@ def langfuse_preset(
owner=ExporterOwner.LANGFUSE_OTEL,
),
],
"mapper_names": ensure_mappers(base.mapper_names, "langfuse"),
"mapper_names": mappers,
}
)
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

@ -7,6 +7,7 @@ from litellm.integrations.otel.presets.utils import ensure_mappers
def langtrace_preset(
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config:
"""Compose the Langtrace mapper on top of the customer's OTLP destination.

View file

@ -11,9 +11,19 @@ from litellm.integrations.otel.model.config import (
def levo_preset(
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config:
cfg = _V1Levo.get_levo_config()
base = config_overrides or OpenTelemetryV2Config()
# ``get_levo_config()`` raises without Levo credentials. Propagate that raise for a
# global callback so a misconfigured deployment fails loud, but when an admin-owned
# Levo destination is the reason for construction it carries its own per-tenant
# credentials, so degrade to a global-exporter-less config rather than raising.
try:
cfg = _V1Levo.get_levo_config()
except Exception:
if not allow_missing_credentials:
raise
return base
return base.model_copy(
update={
"exporters": [

View file

@ -1,5 +1,7 @@
"""Arize-Phoenix preset."""
import os
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -23,25 +25,40 @@ class _PhoenixSettings(BaseSettings):
)
_PHOENIX_ENV_VARS = (
"PHOENIX_API_KEY",
"PHOENIX_COLLECTOR_ENDPOINT",
"PHOENIX_COLLECTOR_HTTP_ENDPOINT",
)
def phoenix_preset(
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config:
cfg = _V1Phoenix.get_arize_phoenix_config()
headers = cfg.otlp_auth_headers if hasattr(cfg, "otlp_auth_headers") else None
project_name = _PhoenixSettings().project_name
base = config_overrides or OpenTelemetryV2Config()
# Contribute the global Phoenix exporter only when Phoenix is configured (a
# cloud API key or a collector endpoint). Otherwise the config defaults to
# http://localhost:6006 and would export there even when the operator only
# uses admin-owned Phoenix destinations.
if any(os.environ.get(v) for v in _PHOENIX_ENV_VARS):
cfg = _V1Phoenix.get_arize_phoenix_config()
headers = cfg.otlp_auth_headers if hasattr(cfg, "otlp_auth_headers") else None
global_exporter = (
ExporterSpec(
kind=cfg.protocol if hasattr(cfg, "protocol") else "otlp_http",
endpoint=cfg.endpoint,
headers=headers,
owner=ExporterOwner.ARIZE_PHOENIX,
),
)
else:
global_exporter = ()
return base.model_copy(
update={
"exporters": [
*base.exporters,
ExporterSpec(
kind=cfg.protocol if hasattr(cfg, "protocol") else "otlp_http",
endpoint=cfg.endpoint,
headers=headers,
owner=ExporterOwner.ARIZE_PHOENIX,
),
],
"exporters": [*base.exporters, *global_exporter],
"mapper_names": ensure_mappers(base.mapper_names, "openinference"),
"resource_attributes": {
**base.resource_attributes,

View file

@ -6,19 +6,28 @@ 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(
*,
config_overrides: OpenTelemetryV2Config | None = None,
allow_missing_credentials: bool = False,
) -> OpenTelemetryV2Config:
weave_cfg = get_weave_otel_config()
base = config_overrides or OpenTelemetryV2Config()
# Weave consumes OpenInference + a small Weave-specific overlay.
mappers = ensure_mappers(base.mapper_names, "openinference", "weave")
# ``get_weave_otel_config()`` raises without W&B credentials. Propagate that raise
# for a global callback so a misconfigured deployment fails loud, but when an
# admin-owned Weave destination is the reason for construction it carries its own
# per-tenant credentials, so degrade to a (global-exporter-less) mapper-only config
# -- otherwise the gen-AI span falls to the generic logger and never reaches Weave.
try:
weave_cfg = get_weave_otel_config()
except Exception:
if not allow_missing_credentials:
raise
return base.model_copy(update={"mapper_names": mappers})
return base.model_copy(
update={
"exporters": [
@ -30,19 +39,6 @@ def weave_preset(
owner=ExporterOwner.WEAVE_OTEL,
),
],
# Weave consumes OpenInference + a small Weave-specific overlay.
"mapper_names": ensure_mappers(base.mapper_names, "openinference", "weave"),
"mapper_names": mappers,
}
)
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:
@ -102,4 +102,19 @@ def initialize_standard_callback_dynamic_params(
validate_no_callback_env_reference(param, _param_value, source="metadata")
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. The proxy stamps them
# onto ``data["litellm_metadata"]["otel_destinations"]`` -- ``litellm_metadata``
# is in ``all_litellm_params``, so it is scrubbed from the body before it reaches
# the provider. ``otel_destinations`` is intentionally NOT a top-level key so an
# unknown field cannot leak to provider APIs. Read from ``litellm_metadata`` only,
# never from request ``metadata``, and never via ``_supported_callback_params``,
# so a request body cannot set or select a trace destination.
proxy_metadata = kwargs.get("litellm_metadata") or {}
otel_destinations = proxy_metadata.get("otel_destinations") if isinstance(proxy_metadata, dict) else None
if isinstance(otel_destinations, list):
standard_callback_dynamic_params["otel_destinations"] = cast(
"list[OtelDestinationParams]", otel_destinations
)
return standard_callback_dynamic_params

View file

@ -3888,9 +3888,9 @@ def _init_custom_logger_compatible_class(
otel_config = OpenTelemetryConfig(
exporter="otlp_http",
endpoint="https://langtrace.ai/api/trace",
endpoint="https://app.langtrace.ai/api/trace",
)
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = f"api_key={os.getenv('LANGTRACE_API_KEY')}"
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = f"x-api-key={os.getenv('LANGTRACE_API_KEY')}"
for callback in _in_memory_loggers:
if isinstance(callback, OpenTelemetry) and callback.callback_name == "langtrace":
return callback # type: ignore
@ -3952,6 +3952,13 @@ def _init_custom_logger_compatible_class(
_otel_logger = WeaveOtelLogger(config=otel_config, callback_name="weave_otel")
_in_memory_loggers.append(_otel_logger)
return _otel_logger # type: ignore
elif logging_integration == "generic":
# Generic OTLP passthrough: a vendor-neutral OpenTelemetryV2 logger whose
# per-destination exporter is attached by the admin-owned destination, so a
# ``generic`` destination gets the full trace (incl. the gen-AI span), not
# just proxy-internal spans. Only meaningful as an admin-owned destination,
# so there is no legacy fallback: None when no v2 logger is constructed.
return _maybe_construct_otel_v2("generic", _in_memory_loggers) # type: ignore
elif logging_integration == "pagerduty":
for callback in _in_memory_loggers:
if isinstance(callback, PagerDutyAlerting):
@ -4076,16 +4083,39 @@ def _init_custom_logger_compatible_class(
return None
def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list) -> Optional[Any]:
"""If ``LITELLM_OTEL_V2`` is on, build (or reuse) a single ``OpenTelemetryV2``
instance configured via the preset for ``callback_name``.
def _has_admin_owned_logging_destination(callback_name: str) -> bool:
"""Whether an admin has registered a logging destination for this backend.
Returns ``None`` when V2 is off OR when there's no preset registered for
Admin-owned trace destinations (``logging`` credentials, created from the UI)
are an OTEL v2 feature: the v2 logger fans a request's spans out to each
destination using that destination's own credentials. So when one exists for
``callback_name`` the v2 logger must own the backend even if the global
``LITELLM_OTEL_V2`` flag is off, otherwise activation falls back to the legacy
global logger, which ignores the per-destination credentials and exports with
whatever (often absent) global env credentials are set.
"""
import litellm
return any(
(info := credential.credential_info or {}).get("credential_type") == "logging"
and info.get("description") == callback_name
for credential in litellm.credential_list
)
def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list) -> Optional[Any]:
"""Build (or reuse) a single ``OpenTelemetryV2`` instance configured via the
preset for ``callback_name`` when V2 owns this backend.
V2 owns the backend when the global ``LITELLM_OTEL_V2`` flag is on, or when an
admin-owned logging destination is registered for it (which is itself a V2-only
feature). Returns ``None`` otherwise, or when there's no preset registered for
``callback_name`` callers should then fall through to the legacy path.
"""
from litellm.integrations.otel.model.config import is_otel_v2_enabled
if not is_otel_v2_enabled():
has_admin_dest = _has_admin_owned_logging_destination(callback_name)
if not is_otel_v2_enabled() and not has_admin_dest:
return None
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.presets import PRESET_BY_CALLBACK
@ -4097,10 +4127,13 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list) -> Op
if isinstance(callback, OpenTelemetryV2) and getattr(callback, "callback_name", None) == callback_name:
return callback
try:
config = preset_fn()
# An admin-owned destination carries its own per-tenant credentials, so a
# credential-mandatory preset may degrade rather than raise. A purely global
# callback with no destination must still raise on missing credentials; the
# raise is swallowed here so the caller defers to the legacy path and customers
# get the same loud error story they had before V2 landed.
config = preset_fn(allow_missing_credentials=has_admin_dest)
except Exception:
# If env vars are missing or the preset raises, defer to the legacy path
# so customers get the same error story they had before V2 landed.
return None
v2_logger = OpenTelemetryV2(config=config, callback_name=callback_name)
_in_memory_loggers.append(v2_logger)
@ -4271,6 +4304,12 @@ def get_custom_logger_compatible_class(
for callback in _in_memory_loggers:
if isinstance(callback, ArizeLogger) and callback.callback_name == "arize":
return callback
elif logging_integration == "generic":
from litellm.integrations.otel.logger import OpenTelemetryV2
for callback in _in_memory_loggers:
if isinstance(callback, OpenTelemetryV2) and getattr(callback, "callback_name", None) == "generic":
return callback
elif logging_integration == "logfire":
if "LOGFIRE_TOKEN" not in os.environ:
raise ValueError("LOGFIRE_TOKEN not found in environment variables")

View file

@ -7,7 +7,7 @@ layer; ``litellm.types.utils`` re-exports them for backwards compatibility.
from typing import Optional
from pydantic import BaseModel, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
class CredentialBase(BaseModel):
@ -29,3 +29,51 @@ class CreateCredentialItem(CredentialBase):
if not values.get("credential_values") and not values.get("model_id"):
raise ValueError("Either credential_values or model_id must be set")
return values
class UpdateCredentialItem(BaseModel):
"""PATCH body for ``/credentials/{name}``.
Both ``credential_values`` and ``credential_info`` are optional so a caller
can patch one without sending the other (team-admins patching access without
knowing the upstream secrets; proxy admins rotating values without touching
access). ``credential_name`` is optional because most patches don't rename.
"""
credential_name: Optional[str] = None
credential_values: Optional[dict] = None
credential_info: Optional[dict] = None
class CredentialAccess(BaseModel):
"""Destination-side access list on a logging credential.
``global`` is exposed via the JSON name "global" through a field alias since
that's a Python keyword. ``populate_by_name`` keeps internal Python code
using ``global_`` working while JSON in/out uses "global".
"""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
global_: bool = Field(default=False, alias="global")
teams: tuple[str, ...] = ()
orgs: tuple[str, ...] = ()
class CredentialInfo(BaseModel):
"""Typed shape of ``credential_info`` for the access-control decider.
Existing stored credentials carry arbitrary extra fields (e.g.
``custom_llm_provider``); ``extra="allow"`` preserves them. The decider
inspects ``model_fields_set`` to learn which fields the caller actually
patched, which is what Pydantic gives us natively without dict-key spelunking.
"""
model_config = ConfigDict(extra="allow")
credential_type: Optional[str] = None
description: Optional[str] = None
host: Optional[str] = None
endpoint: Optional[str] = None
access: Optional[CredentialAccess] = None
auto_enable: bool = False

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,
)
@ -762,6 +765,12 @@ class LiteLLMRoutes(enum.Enum):
# Team guardrail submissions - endpoint scopes results to caller's teams (non-admin)
"/guardrails/submissions",
"/guardrails/submissions/{guardrail_id}",
# Logging-credential routes. GET filters to logging-typed for non-admins;
# PATCH delegates to decide_credential_patch in credential_endpoints, which
# only allows a team-admin to append their own team_id to access.teams.
# POST and DELETE stay proxy-admin only via is_admin_gated_credential_info.
"/credentials",
"/credentials/{credential_name}",
] # routes that manage their own allowed/disallowed logic
## Org Admin Routes ##
@ -1825,7 +1834,7 @@ 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(f"Invalid callback variable: {key}. Must be one of {valid_keys}")
@ -1864,7 +1873,7 @@ 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

@ -911,6 +911,46 @@ async def _resolve_jwt_to_virtual_key(
return None
async def _hoist_request_destinations(request: Request, user_api_key_dict: UserAPIKeyAuth) -> None:
"""Resolve admin-owned OTEL destinations for this request and anchor them.
Runs after the auth builder, while we are still inside the request task, so
the ``ContextVar`` is visible to every ``SpanProcessor.on_end`` that fires
for spans this request opens. Stashes the same list on ``request.state`` so
``_apply_admin_logging_exporters`` can reuse it without a second DB pass.
Best-effort: a resolver failure must not break the request. The contextvar
is left at its default (empty tuple), so the fan-out processor no-ops.
"""
try:
from litellm.integrations.otel.model.destination import OtelDestination
from litellm.integrations.otel.plumbing.context import (
set_request_destinations,
)
from litellm.proxy.litellm_pre_call_utils import (
_resolve_logging_exporters,
)
destinations_raw, _backends = await _resolve_logging_exporters(user_api_key_dict)
destinations = tuple(
OtelDestination(
callback_name=item.get("callback_name"),
endpoint=item.get("endpoint", ""),
headers=item.get("headers") or {},
resource_attributes=item.get("resource_attributes") or {},
)
for item in destinations_raw
if isinstance(item, dict) and item.get("endpoint")
)
set_request_destinations(destinations)
try:
request.state.otel_destinations = destinations_raw
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
verbose_proxy_logger.debug("OTel V2: hoist destination resolution failed: %s", exc)
def _ensure_parent_otel_span_on_request_state(request: Request) -> None:
"""Idempotently create the OTEL SERVER span and stash it on
``request.state.parent_otel_span``. Safe to call multiple times.
@ -1260,6 +1300,8 @@ async def _user_api_key_auth_builder(
valid_token = auto_registered
api_key = valid_token.token or ""
await _hoist_request_destinations(request, valid_token)
# Check if model has zero cost - if so, skip all budget checks
model = _get_model_from_request_context(
request_data=request_data,
@ -1593,6 +1635,8 @@ async def _user_api_key_auth_builder(
user_obj: Optional[LiteLLM_UserTable] = None
valid_token_dict: dict = {}
if valid_token is not None:
valid_token.parent_otel_span = parent_otel_span
await _hoist_request_destinations(request, valid_token)
# Got Valid Token from Cache, DB
# Run checks for
# 1. If token can call model
@ -2422,6 +2466,15 @@ async def user_api_key_auth(
)
user_api_key_auth_obj.budget_reservation = None
# Admin-resolved OTEL destinations: anchor them on this request's task
# context BEFORE downstream spans close, so the global-provider fan-out
# processor forwards every span (server, auth, db, batch-write) to the
# admin-assigned per-tenant backends -- not just the gen-AI span the
# ``TenantTracerCache`` already routes. Also stashed on ``request.state``
# so ``_apply_admin_logging_exporters`` reuses the result instead of
# re-resolving.
await _hoist_request_destinations(request, user_api_key_auth_obj)
## ENSURE DISABLE ROUTE WORKS ACROSS ALL USER AUTH FLOWS ##
RouteChecks.should_call_route(route=route, valid_token=user_api_key_auth_obj, request=request)

View file

@ -0,0 +1,132 @@
"""Pure tagged-union decision for who can patch a logging-credential.
A logging credential controls where other tenants' traces are exported, so
``credential_info.access`` is the only field a non-admin caller may touch, and
only to add their own team_id(s) to ``access.teams``. Everything else
(values, host, type, description, ``global``, ``orgs``, foreign team_ids,
or removing existing grants) stays proxy-admin only.
The decision is a value (Allow vs Deny(reason)), kept separate from the
endpoint so it can be unit-tested exhaustively without spinning up FastAPI.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Mapping
from litellm.models.credentials import CredentialAccess, CredentialInfo
OPAQUE_DENY_REASON = "Only the proxy admin can manage logging credentials"
@dataclass(frozen=True, slots=True)
class Allow:
tag: Literal["allow"] = "allow"
@dataclass(frozen=True, slots=True)
class Deny:
reason: str
# When True the reason is derived from caller input (e.g. they typed a
# foreign team_id) and is safe to surface. When False the reason would
# confirm the stored credential is a logging destination; the endpoint
# collapses these to OPAQUE_DENY_REASON so PATCH /credentials/{name}
# can't be used as an existence oracle by a non-admin caller.
from_user_input: bool = False
tag: Literal["deny"] = "deny"
Decision = Allow | Deny
_IMMUTABLE_INFO_FIELDS = frozenset({"credential_type", "description", "host", "endpoint"})
def _patched_fields(info: CredentialInfo | None) -> frozenset[str]:
"""Names of credential_info fields the caller actually set in their patch."""
if info is None:
return frozenset()
return frozenset(info.model_fields_set) | frozenset(info.model_extra.keys() if info.model_extra else ())
def _access_teams(access: CredentialAccess | None) -> frozenset[str]:
return frozenset(access.teams) if access is not None else frozenset()
def decide_credential_patch(
*,
is_proxy_admin: bool,
caller_team_admin_ids: frozenset[str],
existing_info: CredentialInfo | None,
patch_info: CredentialInfo | None,
patch_values: Mapping[str, object] | None,
patch_name_changed: bool,
) -> Decision:
"""Return Allow or Deny(reason) for a PATCH /credentials/{name} request.
Proxy admins always pass. A team admin only passes when the patch (a) does
not change ``credential_values`` or ``credential_name``, (b) does not
modify any immutable ``credential_info`` field, and (c) limits its
``access`` change to appending team_ids the caller is team-admin of to
``access.teams`` (no removals, no foreign ids, no ``global``/``orgs``
edits).
"""
if is_proxy_admin:
return Allow()
if not caller_team_admin_ids:
return Deny(OPAQUE_DENY_REASON)
if patch_name_changed:
return Deny("credential_name is proxy-admin only")
if patch_values:
return Deny("credential_values is proxy-admin only")
touched = _patched_fields(patch_info)
if not touched:
return Deny("patch must set credential_info.access for team-admin writes")
forbidden = touched & _IMMUTABLE_INFO_FIELDS
if forbidden:
return Deny("credential_info fields are proxy-admin only: " + ", ".join(sorted(forbidden)))
if touched - {"access"}:
return Deny("team-admin may only patch credential_info.access; got: " + ", ".join(sorted(touched)))
assert patch_info is not None
patch_access = patch_info.access
if patch_access is None:
return Deny("credential_info.access must be set for team-admin writes")
# Touching global/orgs is allowed when the value matches the stored state
# (the UI's edit modal sends the full access object back so unchecking
# revokes; a no-op resend of global=false / orgs=[] must not be rejected).
# Only block when the caller would actually CHANGE these.
existing_access = existing_info.access if existing_info is not None else None
access_touched = frozenset(patch_access.model_fields_set)
existing_global = existing_access.global_ if existing_access is not None else False
existing_orgs = frozenset(existing_access.orgs) if existing_access is not None else frozenset()
if "global_" in access_touched and patch_access.global_ != existing_global:
return Deny("access.global is proxy-admin only")
if "orgs" in access_touched and frozenset(patch_access.orgs) != existing_orgs:
return Deny("access.orgs is proxy-admin only")
existing_teams = _access_teams(existing_access)
patch_teams = _access_teams(patch_access)
foreign_removed = (existing_teams - patch_teams) - caller_team_admin_ids
if foreign_removed:
# Do NOT echo the foreign team_ids -- they're stored values the
# caller didn't send, so naming them would leak access list members.
return Deny("team-admin may only revoke their own team grants")
foreign_added = (patch_teams - existing_teams) - caller_team_admin_ids
if foreign_added:
return Deny(
"team-admin may only grant their own team_ids: " + ", ".join(sorted(foreign_added)),
from_user_input=True,
)
return Allow()

View file

@ -2,24 +2,164 @@
CRUD endpoints for storing reusable credentials.
"""
from typing import Optional
from typing import TYPE_CHECKING, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Path
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
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 pydantic import ValidationError
from litellm.models.credentials import CredentialInfo
from litellm.proxy.credential_endpoints.access_decision import (
OPAQUE_DENY_REASON,
Allow,
Deny,
decide_credential_patch,
)
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
from litellm.types.utils import (
CreateCredentialItem,
CredentialItem,
UpdateCredentialItem,
)
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 _is_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> bool:
return user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
def _summarize_validation_error(ve: ValidationError) -> str:
parts = (".".join(str(loc) for loc in err["loc"]) + ": " + err["msg"] for err in ve.errors())
return "; ".join(parts)
async def _caller_grantable_team_ids(
user_api_key_dict: UserAPIKeyAuth, prisma_client: "Optional[PrismaClient]"
) -> frozenset[str]:
"""Team ids the caller may add to / remove from a destination's access.teams.
Two paths to grantability:
1. Direct team-admin: caller is admin of the team (role=admin in
``members_with_roles``).
2. Via org-admin: caller is ORG_ADMIN of the team's organization. Org
admins manage every team in their org, even teams they aren't a
direct member of.
Empty when the caller has no user_id, no DB connection, or admins
nothing. Uses cached ``get_user_object`` / ``get_team_object`` plus one
bounded query for org teams; the role match is done in Python because
``members_with_roles`` is a JSON column.
"""
if user_api_key_dict.user_id is None or prisma_client is None:
return frozenset()
from litellm.proxy.auth.auth_checks import get_team_object, get_user_object
from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
try:
user_obj = await get_user_object(
user_id=user_api_key_dict.user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
parent_otel_span=user_api_key_dict.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
if user_obj is None:
return frozenset()
# Direct team-admin grants: walk the caller's own team list.
team_admin_of: set[str] = set()
for team_id in [tid for tid in (getattr(user_obj, "teams", None) or []) if isinstance(tid, str)]:
team_obj = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_dict.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
if any(
member.user_id == user_api_key_dict.user_id and member.role == "admin"
for member in (team_obj.members_with_roles or [])
):
team_admin_of.add(team_id)
# Org-admin grants: every team in any org the caller admins, even if
# the caller isn't a direct member of that team.
org_admin_of: list[str] = [
m.organization_id
for m in (user_obj.organization_memberships or [])
if m.organization_id and m.user_role == LitellmUserRoles.ORG_ADMIN.value
]
org_grantable: set[str] = set()
if org_admin_of:
org_teams = await prisma_client.db.litellm_teamtable.find_many( # type: ignore[union-attr]
where={"organization_id": {"in": org_admin_of}}
)
org_grantable = {t.team_id for t in org_teams if t.team_id}
return frozenset(team_admin_of | org_grantable)
except Exception: # noqa: BLE001
# Best-effort lookup. A miss here means the PATCH decider will deny any
# patch other than a no-op, which is the safe fallback.
verbose_proxy_logger.exception("team-admin lookup failed")
return frozenset()
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,
)
async def _credential_for_admin_gate(credential_name: str, prisma_client: object) -> Optional[CredentialItem]:
"""Authoritative credential lookup for the admin gate on update/delete.
The in-process ``litellm.credential_list`` can be stale: a credential created
via the API on another horizontally-scaled instance, or before a restart,
exists only in the DB. Gating on the in-memory copy alone would let a logging
credential that isn't resident be updated/deleted without the proxy-admin
check. Prefer the in-memory copy, fall back to the DB so the gate sees the
real ``credential_info``.
"""
existing = _credential_in_memory(credential_name)
if existing is not None:
return existing
if prisma_client is None:
return None
try:
return await CredentialsRepository(
prisma_client # type: ignore[arg-type]
).find_by_name(credential_name)
except Exception: # noqa: BLE001
return None
class CredentialHelperUtils:
@staticmethod
def encrypt_credential_values(
@ -57,6 +197,12 @@ async def create_credential(
"""
from litellm.proxy.proxy_server import llm_router, prisma_client
# POST stays proxy-admin only across the board: route gate was widened so
# team-admins can PATCH access on existing logging destinations, but
# creation of any credential (logging or provider) remains admin-only.
_require_proxy_admin(user_api_key_dict)
validate_credential_access(credential.credential_info)
try:
if prisma_client is None:
raise HTTPException(
@ -120,17 +266,48 @@ async def get_credentials(
):
"""
[BETA] endpoint. This might change unexpectedly.
Proxy admins see every credential (values masked). Team-admins and
org-admins see only logging-typed destinations so they can self-assign
them; provider credentials stay invisible to non-PROXY_ADMINs. Plain
internal users with no team-admin or org-admin status get 403 they
have no use for the list and shouldn't see destination names, hosts,
or scope metadata (Veria F2).
"""
from litellm.proxy.proxy_server import prisma_client
try:
if _is_proxy_admin(user_api_key_dict):
visible = list(litellm.credential_list)
else:
grantable = await _caller_grantable_team_ids(user_api_key_dict, prisma_client)
if not grantable:
raise HTTPException(
status_code=403,
detail={
"error": (
"Listing logging destinations requires team-admin or "
"org-admin status. Ask your proxy admin to add you to a "
"team or org."
)
},
)
visible = [
credential
for credential in litellm.credential_list
if is_admin_gated_credential_info(credential.credential_info)
]
masked_credentials = [
{
"credential_name": credential.credential_name,
"credential_values": _get_masked_values(credential.credential_values),
"credential_info": credential.credential_info,
}
for credential in litellm.credential_list
for credential in visible
]
return {"success": True, "credentials": masked_credentials}
except HTTPException:
raise
except Exception as e:
return handle_exception_on_proxy(e)
@ -230,6 +407,10 @@ async def delete_credential(
"""
from litellm.proxy.proxy_server import prisma_client
# DELETE stays proxy-admin only. The route gate lets team-admins reach
# /credentials/{name} for PATCH; reject any DELETE that isn't proxy-admin.
_require_proxy_admin(user_api_key_dict)
try:
if prisma_client is None:
raise HTTPException(
@ -274,16 +455,97 @@ 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.teams) preserves credential_type/description/host AND the untouched
# access subfields (global/orgs/other teams in access). See
# _merge_credential_info for the surgical-access reasoning.
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)
_merge_credential_info(merged_credential.credential_info, encrypted_credential.credential_info)
return merged_credential
def _merge_credential_info(into: dict, patch: dict) -> None:
"""Merge ``patch`` into ``into`` in place, with surgical access subfields.
A prior top-level dict.update let a patch like ``{access: {teams: [...]}}``
replace the entire stored ``access`` object, wiping ``access.global=true``
and ``access.orgs`` entries that the decider intentionally protected by
refusing to allow them in the patch (Veria F1: scope tampering). Now
``access`` is merged subfield-by-subfield, so a non-admin patch carrying
only ``access.teams`` keeps existing ``access.global`` / ``access.orgs``
intact. The DB write and the in-memory cache sync both call this so the
two stores can't drift.
"""
patch_copy = dict(patch)
patch_access = patch_copy.pop("access", None)
into.update(patch_copy)
if patch_access is None:
return
existing_access = into.get("access")
if isinstance(existing_access, dict) and isinstance(patch_access, dict):
existing_access.update(patch_access)
else:
into["access"] = patch_access
async def _authorize_credential_patch(
*,
credential_name: str,
patch: UpdateCredentialItem,
existing: Optional[CredentialItem],
user_api_key_dict: UserAPIKeyAuth,
prisma_client: "Optional[PrismaClient]",
) -> None:
"""Raise 403 unless the caller is allowed to apply ``patch`` to ``existing``.
The decider widening only applies when the STORED credential is a logging
destination -- a patch body alone can't promote a provider credential into
the decider's allowed paths (Cursor BugBot bypass: ``is_admin_gated_credential_info``
returned True for any patch carrying ``access``, so a team-admin could PATCH
``access.teams`` onto a provider credential and reach the decider).
"""
existing_is_logging_gated = existing is not None and is_admin_gated_credential_info(existing.credential_info)
if not existing_is_logging_gated:
_require_proxy_admin(user_api_key_dict)
return
is_admin = _is_proxy_admin(user_api_key_dict)
team_admin_ids = frozenset() if is_admin else await _caller_grantable_team_ids(user_api_key_dict, prisma_client)
try:
patch_info_typed = (
CredentialInfo.model_validate(patch.credential_info) if patch.credential_info is not None else None
)
except ValidationError as ve:
raise HTTPException(status_code=400, detail={"error": _summarize_validation_error(ve)})
assert existing is not None # narrowed by existing_is_logging_gated
existing_info_typed = CredentialInfo.model_validate(existing.credential_info)
decision = decide_credential_patch(
is_proxy_admin=is_admin,
caller_team_admin_ids=team_admin_ids,
existing_info=existing_info_typed,
patch_info=patch_info_typed,
patch_values=patch.credential_values,
patch_name_changed=(patch.credential_name is not None and patch.credential_name != credential_name),
)
if isinstance(decision, Deny):
reason = decision.reason if decision.from_user_input else OPAQUE_DENY_REASON
raise HTTPException(status_code=403, detail={"error": reason})
assert isinstance(decision, Allow)
def _patch_to_credential_item(patch: UpdateCredentialItem, credential_name: str) -> CredentialItem:
"""Translate the partial PATCH body into the legacy CredentialItem shape
the downstream merge expects (non-None dicts)."""
return CredentialItem(
credential_name=patch.credential_name or credential_name,
credential_values=patch.credential_values or {},
credential_info=patch.credential_info or {},
)
@router.patch(
"/credentials/{credential_name:path}",
dependencies=[Depends(user_api_key_auth)],
@ -292,15 +554,30 @@ def update_db_credential(
async def update_credential(
request: Request,
fastapi_response: Response,
credential: CredentialItem,
credential: UpdateCredentialItem,
credential_name: str = Path(..., description="The credential name, percent-decoded; may contain slashes"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
[BETA] endpoint. This might change unexpectedly.
Both ``credential_values`` and ``credential_info`` are optional; a team-admin
typically patches only ``credential_info.access`` to grant or revoke their
own team. A proxy admin may patch either or both. See
``decide_credential_patch`` for the exact contract.
"""
from litellm.proxy.proxy_server import prisma_client
existing = await _credential_for_admin_gate(credential_name, prisma_client)
await _authorize_credential_patch(
credential_name=credential_name,
patch=credential,
existing=existing,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
)
validate_credential_access(credential.credential_info)
try:
if prisma_client is None:
raise HTTPException(
@ -311,7 +588,7 @@ async def update_credential(
db_credential = await credentials_repository.find_by_name(credential_name)
if db_credential is None:
raise HTTPException(status_code=404, detail="Credential not found in DB.")
merged_credential = update_db_credential(db_credential, credential)
merged_credential = update_db_credential(db_credential, _patch_to_credential_item(credential, credential_name))
credential_object_jsonified = jsonify_object(merged_credential.model_dump())
await credentials_repository.update_by_name(
credential_name,
@ -320,32 +597,49 @@ async def update_credential(
"updated_by": user_api_key_dict.user_id,
},
)
# Sync in-memory credential_list (skip if not in memory - e.g., proxy restarted)
new_name = merged_credential.credential_name
existing_in_memory: Optional[CredentialItem] = None
for cred in litellm.credential_list:
if cred.credential_name == credential_name:
existing_in_memory = cred
break
if existing_in_memory is not None:
in_memory_values = dict(existing_in_memory.credential_values or {})
if credential.credential_values:
in_memory_values.update(credential.credential_values)
in_memory_info = dict(existing_in_memory.credential_info or {})
if credential.credential_info:
in_memory_info.update(credential.credential_info)
updated_in_memory = CredentialItem(
credential_name=new_name,
credential_values=in_memory_values,
credential_info=in_memory_info,
)
# Remove old entry if renamed, then use upsert_credentials to handle duplicates
if new_name != credential_name:
litellm.credential_list = [c for c in litellm.credential_list if c.credential_name != credential_name]
CredentialAccessor.upsert_credentials([updated_in_memory])
_sync_in_memory_credential(
old_name=credential_name,
merged=merged_credential,
patch=credential,
)
return {"success": True, "message": "Credential updated successfully"}
except Exception as e:
return handle_exception_on_proxy(e)
def _sync_in_memory_credential(
*,
old_name: str,
merged: CredentialItem,
patch: UpdateCredentialItem,
) -> None:
"""Mirror the DB write into ``litellm.credential_list``.
Skips when the credential isn't resident in memory (e.g. created on
another scaled instance, restored from DB on the next reload). The
in-memory ``credential_info`` is merged subfield-by-subfield via
``_merge_credential_info`` so a partial patch can't clobber stored
``access`` subfields it didn't touch.
"""
existing_in_memory: Optional[CredentialItem] = None
for cred in litellm.credential_list:
if cred.credential_name == old_name:
existing_in_memory = cred
break
if existing_in_memory is None:
return
in_memory_values = dict(existing_in_memory.credential_values or {})
if patch.credential_values:
in_memory_values.update(patch.credential_values)
in_memory_info = dict(existing_in_memory.credential_info or {})
if patch.credential_info:
_merge_credential_info(in_memory_info, patch.credential_info)
updated_in_memory = CredentialItem(
credential_name=merged.credential_name,
credential_values=in_memory_values,
credential_info=in_memory_info,
)
if merged.credential_name != old_name:
litellm.credential_list = [c for c in litellm.credential_list if c.credential_name != old_name]
CredentialAccessor.upsert_credentials([updated_in_memory])

View file

@ -81,6 +81,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
@ -510,6 +512,235 @@ class KeyAndTeamLoggingSettings:
return None
async def _effective_org_id(user_api_key_dict: UserAPIKeyAuth) -> Optional[str]:
"""The org this request belongs to, falling back to the team's org when the token
carries none. Team keys frequently have no ``org_id`` on the token, so without this
an org-scoped destination would be invisible at request time even though the write
gate (which loads the team) accepted it. Mirrors the fallback in ``_check_org_budget``.
"""
if user_api_key_dict.org_id is not None:
return user_api_key_dict.org_id
team_id = user_api_key_dict.team_id
if team_id is None:
return None
from litellm.proxy import proxy_server
from litellm.proxy.auth.auth_checks import get_team_object
if proxy_server.prisma_client is None:
return None
try:
team_obj = await get_team_object(
team_id=team_id,
prisma_client=proxy_server.prisma_client,
user_api_key_cache=proxy_server.user_api_key_cache,
parent_otel_span=getattr(user_api_key_dict, "parent_otel_span", None),
check_db_only=True,
)
except HTTPException:
return None
return getattr(team_obj, "organization_id", None)
async def _union_logging_exporter_names(user_api_key_dict: UserAPIKeyAuth, org_id: Optional[str]) -> 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`` using the effective ``org_id`` (token org
or team fallback); 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: # noqa: BLE001
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 org_id and prisma_client is not None:
try:
org_obj = await get_org_object(
org_id=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: # noqa: BLE001
pass
return names
async def _resolve_logging_exporters(
user_api_key_dict: UserAPIKeyAuth,
) -> "tuple[list, list]":
"""Resolve the destinations this request fans out to, as (destinations, backends).
``credential_info.access`` is visibility, not enablement: a granted destination
does NOT fire just because the caller can see it. A destination is selected only
when it is an explicit global/default (``auto_enable``) OR it is named in the
identity chain's ``logging_exporters`` (key + team + org) AND its ``access`` grants
the caller. The visibility re-check is defensive: a name that points at a
destination no longer visible to this identity is ignored, so a stale or
cross-tenant assignment can never route traffic out. Each survivor is built via
``build_destination`` and deduped on (endpoint, headers, resource attributes).
Returns ([], []) when nothing is selected (default-deny).
"""
from litellm.integrations.otel.presets.destinations import build_destination
from litellm.proxy.management_endpoints.logging_exporter_access import (
access_grants,
is_auto_enable,
)
team_id = user_api_key_dict.team_id
org_id = await _effective_org_id(user_api_key_dict)
names = await _union_logging_exporter_names(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 is_auto_enable(info):
return True
if credential.credential_name not in names:
return False
return access_grants(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())),
tuple(sorted(destination.resource_attributes.items())),
): (
backend,
destination,
)
for backend, destination in built
}
destinations = [
{
"callback_name": backend,
"endpoint": destination.endpoint,
"headers": destination.headers,
"resource_attributes": destination.resource_attributes,
}
for backend, destination in deduped.values()
]
backends = list(dict.fromkeys(backend for backend, _ in deduped.values()))
return destinations, backends
def _request_destination_from_raw(item: object) -> "OtelDestination | None":
from litellm.integrations.otel.model.destination import OtelDestination
if isinstance(item, OtelDestination):
return item
if not isinstance(item, dict) or not item.get("endpoint"):
return None
try:
return OtelDestination.model_validate(item)
except PydanticValidationError:
return None
def _set_request_otel_destinations(destinations: list) -> None:
from litellm.integrations.otel.plumbing.context import set_request_destinations
set_request_destinations(
tuple(destination for item in destinations if (destination := _request_destination_from_raw(item)) is not None)
)
async def _apply_admin_logging_exporters(
data: dict,
user_api_key_dict: UserAPIKeyAuth,
cached_destinations: "list | None" = None,
) -> None:
"""Stamp the resolved fan-out destinations onto ``data`` and activate their
backends.
The destinations live under ``data["litellm_metadata"]`` (in
``all_litellm_params``, so scrubbed from the provider request body), not a
top-level key, so an unknown field cannot leak to the provider. Default-deny
means an identity with no assignment gets no per-tenant destination here.
``cached_destinations`` -- when ``user_api_key_auth`` already resolved the
destinations on this request (the FastAPI path), reuse the result instead of
running the resolver a second time. The SDK path passes ``None`` and the
resolver runs here.
"""
if cached_destinations is not None:
destinations = list(cached_destinations)
backends = list(
dict.fromkeys(
str(d["callback_name"]) for d in destinations if isinstance(d, dict) and d.get("callback_name")
)
)
else:
destinations, backends = await _resolve_logging_exporters(user_api_key_dict)
if not destinations:
return
_set_request_otel_destinations(destinations)
proxy_metadata = data.get("litellm_metadata")
if not isinstance(proxy_metadata, dict):
proxy_metadata = {}
proxy_metadata["otel_destinations"] = destinations
data["litellm_metadata"] = proxy_metadata
# Register on both success and failure: an admin-owned destination must
# capture a failed upstream call (its error gen-AI span) as well as a
# successful one, otherwise a 401/timeout lands a trace with no LLM-call span.
existing_success = data.get("success_callback") or []
data["success_callback"] = list(dict.fromkeys([*existing_success, *backends]))
existing_failure = data.get("failure_callback") or []
data["failure_callback"] = list(dict.fromkeys([*existing_failure, *backends]))
def _get_dynamic_logging_metadata(
user_api_key_dict: UserAPIKeyAuth, proxy_config: ProxyConfig
) -> Optional[TeamCallbackMetadata]:
@ -1649,6 +1880,14 @@ 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. Drop any value carried in the request at either the
# top level OR inside litellm_metadata (the resolver stashes admin-resolved values
# in litellm_metadata.otel_destinations; we wipe the client's first so injection
# via either spot is inert).
data.pop("otel_destinations", None)
if isinstance(data.get("litellm_metadata"), dict):
data["litellm_metadata"].pop("otel_destinations", None)
callback_settings_obj = _get_dynamic_logging_metadata(
user_api_key_dict=user_api_key_dict, proxy_config=proxy_config
)
@ -1657,10 +1896,17 @@ 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. Reuse
# the result ``user_api_key_auth`` already cached on ``request.state`` so the
# resolver runs once per request, not twice.
cached = getattr(getattr(request, "state", None), "otel_destinations", None)
await _apply_admin_logging_exporters(data, user_api_key_dict, cached_destinations=cached)
# Add disabled callbacks from key metadata
if user_api_key_dict.metadata and "litellm_disabled_callbacks" in user_api_key_dict.metadata:
disabled_callbacks = user_api_key_dict.metadata["litellm_disabled_callbacks"]

View file

@ -111,15 +111,13 @@ def _is_user_team_admin(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_Tea
return False
async def _is_user_org_admin_for_team(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool:
"""
Check if user is an org admin for the team's organization.
async def _is_user_org_admin_for_org_id(user_api_key_dict: UserAPIKeyAuth, organization_id: Optional[str]) -> bool:
"""Check if the caller has the ORG_ADMIN role in the given organization.
Returns True if:
- The team belongs to an organization, AND
- The user has org_admin role in that organization
Returns False when ``organization_id`` is falsy or the caller has no user_id,
so the caller can pass an optional org_id directly without branching.
"""
if not team_obj.organization_id or not user_api_key_dict.user_id:
if not organization_id or not user_api_key_dict.user_id:
return False
from litellm.proxy.auth.auth_checks import get_user_object
@ -139,11 +137,18 @@ async def _is_user_org_admin_for_team(user_api_key_dict: UserAPIKeyAuth, team_ob
if caller_user is None:
return False
for m in caller_user.organization_memberships or []:
if m.organization_id == team_obj.organization_id and m.user_role == LitellmUserRoles.ORG_ADMIN.value:
return True
return any(
m.organization_id == organization_id and m.user_role == LitellmUserRoles.ORG_ADMIN.value
for m in (caller_user.organization_memberships or [])
)
return False
async def _is_user_org_admin_for_team(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool:
"""Check if user is an org admin for the team's organization."""
return await _is_user_org_admin_for_org_id(
user_api_key_dict=user_api_key_dict,
organization_id=team_obj.organization_id,
)
def _team_member_has_permission(

View file

@ -1400,6 +1400,14 @@ async def generate_key_fn(
"""
try:
from litellm.proxy._types import CommonProxyErrors
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
_is_user_team_admin,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
LOGGING_EXPORTERS_KEY,
validate_logging_exporter_assignment,
)
from litellm.proxy.proxy_server import (
prisma_client,
user_api_key_cache,
@ -1487,6 +1495,27 @@ async def generate_key_fn(
route=KeyManagementRoutes.KEY_GENERATE,
)
# Team-admin of the key's team or org-admin of that team's org may
# write metadata.logging_exporters on team-owned keys. Personal keys
# (no team_table) stay proxy-admin only. Skip the role lookup when
# the field isn't in the payload to keep /key/generate cheap for the
# common case.
if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata:
validate_logging_exporter_assignment(
data.metadata,
user_api_key_dict,
caller_is_team_admin=(
team_table is not None
and _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_table)
),
caller_is_org_admin=(
team_table is not None
and await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_table)
),
scope_team_id=getattr(team_table, "team_id", None),
scope_org_id=getattr(team_table, "organization_id", None),
)
if team_table is not None:
await _check_team_key_limits(
team_table=team_table,
@ -1662,6 +1691,34 @@ async def generate_service_account_key_fn(
route=KeyManagementRoutes.KEY_GENERATE_SERVICE_ACCOUNT,
)
# Same logging_exporters gate as /key/generate. Without this, a caller
# eligible for service-account creation could set metadata.logging_exporters
# and route future traces to a destination they aren't allowed to assign
# (Veria F3). Skip the lookup unless the field is being written.
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
_is_user_team_admin,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
LOGGING_EXPORTERS_KEY,
validate_logging_exporter_assignment,
)
if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata:
validate_logging_exporter_assignment(
data.metadata,
user_api_key_dict,
caller_is_team_admin=(
team_table is not None and _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_table)
),
caller_is_org_admin=(
team_table is not None
and await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_table)
),
scope_team_id=getattr(team_table, "team_id", None),
scope_org_id=getattr(team_table, "organization_id", None),
)
data.user_id = None # do not allow user_id to be set for service account keys
return await _common_key_generation_helper(
@ -2318,7 +2375,7 @@ async def _validate_update_key_data(
@router.post("/key/update", tags=["key management"], dependencies=[Depends(user_api_key_auth)])
@management_endpoint_wrapper
async def update_key_fn(
async def update_key_fn( # noqa: C901
request: Request,
data: UpdateKeyRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
@ -2395,6 +2452,13 @@ async def update_key_fn(
}'
```
"""
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
_is_user_team_admin,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
validate_logging_exporter_assignment,
)
from litellm.proxy.proxy_server import (
llm_router,
premium_user,
@ -2421,6 +2485,43 @@ async def update_key_fn(
prisma_client=prisma_client,
)
# logging-exporters validation runs once the key's team is known so
# a team-admin or org-admin of that team can attach destinations.
# The validator no-ops when the effective value doesn't change; pass
# the stored metadata so removal-via-omission gates too (Veria F4).
if isinstance(data.metadata, dict):
_existing_key_metadata = (
existing_key_row.metadata if isinstance(getattr(existing_key_row, "metadata", None), dict) else None
)
_key_team_id = getattr(existing_key_row, "team_id", None)
_key_team = None
if _key_team_id is not None:
try:
_key_team = await get_team_object(
team_id=_key_team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_dict.parent_otel_span,
check_db_only=True,
)
except HTTPException:
_key_team = None
validate_logging_exporter_assignment(
data.metadata,
user_api_key_dict,
caller_is_team_admin=(
_key_team is not None
and _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=_key_team)
),
caller_is_org_admin=(
_key_team is not None
and await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=_key_team)
),
existing_metadata=_existing_key_metadata,
scope_team_id=getattr(_key_team, "team_id", None),
scope_org_id=getattr(_key_team, "organization_id", None),
)
await _validate_update_key_data(
data=data,
existing_key_row=existing_key_row,
@ -4272,7 +4373,7 @@ async def _execute_virtual_key_regeneration(
dependencies=[Depends(user_api_key_auth)],
)
@management_endpoint_wrapper
async def regenerate_key_fn(
async def regenerate_key_fn( # noqa: C901
key: Optional[str] = None,
data: Optional[RegenerateKeyRequest] = None,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
@ -4460,21 +4561,65 @@ async def regenerate_key_fn(
# Gate access_group_ids on regenerate, same as /key/generate and
# /key/update. Use the existing key's team since the body may omit it.
if data is not None and data.access_group_ids:
regenerate_team_table: Optional[LiteLLM_TeamTableCachedObj] = None
if _key_in_db.team_id is not None:
regenerate_team_table: Optional[LiteLLM_TeamTableCachedObj] = None
if _key_in_db.team_id is not None:
try:
regenerate_team_table = await get_team_object(
team_id=_key_in_db.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
except HTTPException:
regenerate_team_table = None
if data is not None and data.access_group_ids:
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=user_api_key_dict,
team_table=regenerate_team_table,
access_group_ids=data.access_group_ids,
)
# logging_exporters gate on regenerate matches /key/generate and
# /key/update. Without this, a key owner could set
# metadata.logging_exporters on /key/{id}/regenerate and route
# future traces to a destination they aren't allowed to assign
# (Veria F3). The validator no-ops when the effective value
# doesn't change; pass stored metadata so removal-via-omission
# gates too (Veria F4).
if data is not None and isinstance(data.metadata, dict):
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
_is_user_team_admin,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
validate_logging_exporter_assignment,
)
_regen_existing_metadata = (
_key_in_db.metadata if isinstance(getattr(_key_in_db, "metadata", None), dict) else None
)
validate_logging_exporter_assignment(
data.metadata,
user_api_key_dict,
caller_is_team_admin=(
regenerate_team_table is not None
and _is_user_team_admin(
user_api_key_dict=user_api_key_dict,
team_obj=regenerate_team_table,
)
),
caller_is_org_admin=(
regenerate_team_table is not None
and await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict,
team_obj=regenerate_team_table,
)
),
existing_metadata=_regen_existing_metadata,
scope_team_id=getattr(regenerate_team_table, "team_id", None),
scope_org_id=getattr(regenerate_team_table, "organization_id", None),
)
verbose_proxy_logger.info(
"Key regeneration requested: key_alias=%s",
getattr(_key_in_db, "key_alias", None),

View file

@ -0,0 +1,41 @@
"""Shared access predicate for admin-owned logging destinations.
``credential_info.access`` answers "who may see/assign this destination" it is
visibility, decoupled from enablement (which lives in ``metadata.logging_exporters``
and the explicit ``auto_enable`` flag). The request-time resolver and the write-time
validator gate on the SAME predicate so "visible" means the same thing on both sides:
a team/org admin can only assign destinations they can see, and the resolver
defensively re-checks visibility at request time.
"""
from typing import Optional
AUTO_ENABLE_KEY = "auto_enable"
def access_grants(access: object, team_id: Optional[str], org_id: Optional[str]) -> bool:
"""Whether a destination's ``access`` makes it visible to this identity.
``global`` reaches everyone; otherwise the identity's team or org must be listed.
A missing or malformed ``access`` grants no one (fail closed): visibility must be
an explicit admin grant, never an accident of an absent field.
"""
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
def is_auto_enable(credential_info: object) -> bool:
"""Whether a destination is an explicit global/default (auto-enabled everywhere).
This is the deliberate replacement for the old behavior where ``access.global``
implicitly auto-enabled a destination for every request. Enablement is now opt-in:
only ``auto_enable`` turns a destination on without being named.
"""
return isinstance(credential_info, dict) and credential_info.get(AUTO_ENABLE_KEY) is True

View file

@ -0,0 +1,220 @@
"""Validation for admin-owned logging-exporter assignment on key/team/org.
An identity's ``metadata.logging_exporters`` binds it to admin-owned trace
destinations. Every name must be a registered logging credential, the caller must
hold a role that authorizes the write (proxy admin always; team admin and org admin
in specific contexts), AND a non-proxy-admin may only name destinations whose
``credential_info.access`` makes them visible to the scope being written (the key's
team, or the team/org being updated). Visibility and enablement are separate: a
destination granted to a team is assignable by that team's admin, but assigning it is
what enables it. The resolver (``litellm_pre_call_utils``) re-checks visibility at
request time, so this gate and the resolver agree on what "visible" means.
"""
from typing import Optional
from fastapi import HTTPException, status
import litellm
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.logging_exporter_access import (
access_grants,
is_auto_enable,
)
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_credentials_by_name() -> dict[str, dict]:
return {
credential.credential_name: (credential.credential_info or {})
for credential in litellm.credential_list
if (credential.credential_info or {}).get("credential_type") == "logging"
}
def _logging_credential_names() -> set[str]:
return set(_logging_credentials_by_name())
def _reject_unassignable_destinations(
exporters: list[str],
*,
scope_team_id: Optional[str],
scope_org_id: Optional[str],
) -> None:
"""Reject names a non-proxy-admin cannot assign in this scope.
A destination is assignable when it is an explicit global/default
(``auto_enable``) or its ``access`` grants the scope being written (the key's
team, or the team/org being updated). Names are already known logging
credentials by the time this runs, so a missing entry means a benign race; we
fail closed on it.
"""
by_name = _logging_credentials_by_name()
unassignable = [
name
for name in exporters
if not (
is_auto_enable(by_name.get(name))
or access_grants((by_name.get(name) or {}).get("access"), scope_team_id, scope_org_id)
)
]
if unassignable:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": (
"You can only assign logging destinations granted to your team "
f"or organization. Not granted: {unassignable}"
)
},
)
def _validate_exporters_shape_and_names(exporters: object) -> None:
"""Common shape + registry check shared by every entry point."""
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"},
)
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."
)
},
)
def _exporter_value_changes(
requested_metadata: Optional[dict],
existing_metadata: Optional[dict],
) -> bool:
"""True if the effective ``metadata.logging_exporters`` value would change.
An update endpoint that REPLACES stored metadata with ``requested_metadata``
will drop ``logging_exporters`` when the new payload omits it. So a write
requires authorization whenever:
- the new metadata sets ``logging_exporters`` (the previously-handled case), OR
- the new metadata is provided but omits ``logging_exporters`` while the
stored metadata had one (removal-via-omission, Veria F4).
Returns False when stored and requested values match exactly, or when the
update doesn't touch metadata at all.
"""
if not isinstance(requested_metadata, dict):
return False
new_has = LOGGING_EXPORTERS_KEY in requested_metadata
existing = existing_metadata.get(LOGGING_EXPORTERS_KEY) if isinstance(existing_metadata, dict) else None
existing_has = existing is not None
if not new_has and not existing_has:
return False
if new_has and not existing_has:
return True
if not new_has and existing_has:
return True
return requested_metadata.get(LOGGING_EXPORTERS_KEY) != existing
def validate_logging_exporter_assignment(
metadata: Optional[dict],
user_api_key_dict: UserAPIKeyAuth,
*,
caller_is_team_admin: bool = False,
caller_is_org_admin: bool = False,
existing_metadata: Optional[dict] = None,
scope_team_id: Optional[str] = None,
scope_org_id: Optional[str] = None,
) -> None:
"""Validate a ``metadata.logging_exporters`` write on key / team / org endpoints.
No-op when the update does not change the effective ``logging_exporters``
value. Proxy admins always pass. Caller-provided flags widen the allow-list
per endpoint:
- ``/team/update``: pass ``caller_is_org_admin`` from the loaded team's org.
- ``/key/generate``/``/key/update``: pass both flags from the key's team.
- ``/team/new``/``/organization/*``: pass neither (proxy-admin only).
``scope_team_id``/``scope_org_id`` are the team and org the write lands in (the
key's team, or the team/org being updated). A non-proxy-admin may only name
destinations visible to that scope: this is what stops a team admin from routing a
key's traces to a destination scoped to a different team. Proxy admins skip the
scope check; they can assign anything, but the resolver still only fires a named
destination for identities it is visible to.
Update paths replace stored metadata wholesale, so a caller can drop an
admin-assigned exporter by sending ``metadata`` without
``logging_exporters``. Pass ``existing_metadata`` from the loaded row so
removal-via-omission is gated too (Veria F4). On create paths the existing
value is implicitly ``None`` and the validator behaves as before.
Every exporter name (when present) must resolve to a registered logging credential.
"""
if not _exporter_value_changes(metadata, existing_metadata):
return
is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
if not (is_proxy_admin or caller_is_team_admin or caller_is_org_admin):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": (
"Only the proxy admin, a team admin of this team, or an "
"org admin of this team's organization can assign logging "
"exporters"
)
},
)
requested = metadata.get(LOGGING_EXPORTERS_KEY) if isinstance(metadata, dict) else None
if requested is not None:
_validate_exporters_shape_and_names(requested)
if not is_proxy_admin:
_reject_unassignable_destinations(requested, scope_team_id=scope_team_id, scope_org_id=scope_org_id)

View file

@ -198,6 +198,11 @@ 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,
@ -453,6 +458,12 @@ 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):
raise HTTPException(

View file

@ -988,6 +988,13 @@ async def new_team(
```
"""
try:
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_org_id,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
LOGGING_EXPORTERS_KEY,
validate_logging_exporter_assignment,
)
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
)
@ -999,6 +1006,21 @@ async def new_team(
user_api_key_cache,
)
# New team has no admins yet, so only proxy admin or an org admin of
# the destination org may assign logging exporters at creation time.
# Skip the org-admin lookup entirely when the field isn't being
# written, to avoid hitting the cache for unrelated /team/new calls.
if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata:
validate_logging_exporter_assignment(
data.metadata,
user_api_key_dict,
caller_is_org_admin=await _is_user_org_admin_for_org_id(
user_api_key_dict=user_api_key_dict,
organization_id=data.organization_id,
),
scope_org_id=data.organization_id,
)
if prisma_client is None:
raise HTTPException(status_code=500, detail={"error": "No db connected"})
@ -1637,6 +1659,12 @@ async def update_team(
```
"""
try:
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
validate_logging_exporter_assignment,
)
from litellm.proxy.proxy_server import (
litellm_proxy_admin_name,
llm_router,
@ -1685,11 +1713,34 @@ async def update_team(
)
# Verify caller has access to manage this team
team_for_auth = LiteLLM_TeamTable(**existing_team_row.model_dump())
await _verify_team_access(
team_obj=LiteLLM_TeamTable(**existing_team_row.model_dump()),
team_obj=team_for_auth,
user_api_key_dict=user_api_key_dict,
)
# logging_exporters on /team/update is proxy-admin or org-admin only:
# team-admins are blocked at the route gate (test_team_update_authz_
# matrix pins this) and the role matrix documents ❌ for team-admin on
# this path. Pass only the org-admin flag so the validator can't
# silently grant team-admins if the route gate is ever widened. The
# validator no-ops when the effective value doesn't change; pass the
# stored metadata so removal-via-omission gates too (Veria F4).
if isinstance(data.metadata, dict):
existing_team_metadata = (
existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else None
)
validate_logging_exporter_assignment(
data.metadata,
user_api_key_dict,
caller_is_org_admin=await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=team_for_auth
),
existing_metadata=existing_team_metadata,
scope_team_id=getattr(team_for_auth, "team_id", None),
scope_org_id=getattr(team_for_auth, "organization_id", None),
)
_check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team")
if data.soft_budget is not None:

View file

@ -2923,6 +2923,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]
@ -2970,6 +2984,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 ##
@ -3567,6 +3587,9 @@ from litellm.models.credentials import CredentialItem as CredentialItem # noqa:
from litellm.models.credentials import ( # noqa: E402
CreateCredentialItem as CreateCredentialItem,
)
from litellm.models.credentials import ( # noqa: E402
UpdateCredentialItem as UpdateCredentialItem,
)
class ExtractedFileData(TypedDict):

View file

@ -465,6 +465,16 @@ def test_otlp_traces_endpoint_normalization():
norm("https://x.splunk.com/v2/trace/otlp")
== "https://x.splunk.com/v2/trace/otlp"
)
# Langtrace ingests at /api/trace (a complete path, not an OTLP base) — appending
# /v1/traces 404s, so it must be left intact (regression for the double-append bug).
assert (
norm("https://app.langtrace.ai/api/trace")
== "https://app.langtrace.ai/api/trace"
)
assert (
norm("https://app.langtrace.ai/api/trace/")
== "https://app.langtrace.ai/api/trace"
)
assert norm(None) is None

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,352 @@ 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
# --- gen-AI span Resource must match its fanned-out parents ----------------- #
#
# The gen-AI LLM-call span is emitted through the TenantTracerCache clone
# provider, while the proxy-internal spans (server/auth/db) are forwarded by
# TenantFanOutSpanProcessor, which wraps each with the destination's backend-
# required Resource attrs (Arize needs model_id / arize.project.name). If the
# clone provider does NOT also carry those attrs, the gen-AI span reaches Arize
# with only service.name and Arize renders it as an orphaned subtree. These pin
# that the clone Resource carries the same attrs the fan-out wrap injects.
def _dest_with_resource(endpoint, backend, resource_attributes):
return OtelDestination(
endpoint=endpoint,
headers={"Authorization": "Basic AAAA"},
callback_name=backend,
resource_attributes=resource_attributes,
)
def test_clone_config_carries_destination_resource_attrs():
cache = _cache("arize")
new = cache._config_with_destinations(
(
_dest_with_resource(
"https://otlp.arize.com/v1",
"arize",
{"model_id": "team-b-proj", "arize.project.name": "team-b-proj"},
),
)
)
assert new.resource_attributes["model_id"] == "team-b-proj"
assert new.resource_attributes["arize.project.name"] == "team-b-proj"
def test_clone_config_carries_builder_declared_resource_attrs(monkeypatch):
"""End-to-end: an arize credential that omits the project gets ARIZE_PROJECT_NAME
folded in at BUILD time (build_destination), and the clone config then carries
those Resource attrs generically -- the gen-AI span lands in the same project as
its fan-out'd parents. The clone path itself is backend-agnostic; it reads
whatever the builder declared."""
monkeypatch.setenv("ARIZE_PROJECT_NAME", "env-proj")
from litellm.integrations.otel.presets.destinations import build_destination
dest = build_destination("arize", {"arize_space_id": "s", "arize_api_key": "k"})
assert dest is not None
cache = _cache("arize")
new = cache._config_with_destinations((dest,))
assert new.resource_attributes["model_id"] == "env-proj"
assert new.resource_attributes["arize.project.name"] == "env-proj"
def test_clone_provider_emits_genai_span_with_destination_resource():
"""End-to-end regression: the span the clone provider actually exports must
carry the destination's Resource attrs. Pre-fix this Resource was
service.name only, orphaning the gen-AI span in Arize."""
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
from litellm.integrations.otel.plumbing.providers import (
build_tracer_provider,
get_tracer,
)
cfg = OpenTelemetryV2Config(
service_name="litellm-proxy",
exporters=[ExporterSpec(kind="in_memory", owner="arize")],
)
cache = TenantTracerCache(cfg, "arize", "litellm")
dest = _dest_with_resource(
"https://otlp.arize.com/v1",
"arize",
{"model_id": "team-b-proj", "arize.project.name": "team-b-proj"},
)
tracer = cache.tracer_for(get_tracer(build_tracer_provider(cfg)), (dest,))
with tracer.start_as_current_span("chat anthropic-haiku") as span:
span.set_attribute("gen_ai.operation.name", "chat")
provider = next(iter(cache._providers.values()))
provider.force_flush()
captured = []
for proc in provider._active_span_processor._span_processors:
exporter = getattr(proc, "span_exporter", None)
if isinstance(exporter, InMemorySpanExporter):
captured = exporter.get_finished_spans()
assert captured, "clone provider exported no span to its in-memory exporter"
resource_attrs = dict(captured[0].resource.attributes)
assert resource_attrs.get("model_id") == "team-b-proj"
assert resource_attrs.get("arize.project.name") == "team-b-proj"
# --- 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"]
# --- multi-destination, same-backend: group by Resource so each gets its span -- #
#
# A backend that selects its target FROM the Resource (Arize's project) needs a
# differently-tagged span per destination, because a span carries exactly one
# Resource (a TracerProvider property). Pre-fix the gen-AI clone folded every
# destination into ONE last-wins Resource, so two Arize projects collapsed to one
# and only that project received the gen-AI span. ``tracers_for`` groups by
# ``destination_resource_attrs`` and returns one tracer (one provider, one Resource)
# per distinct group. Header-routed backends declare no Resource attrs, so they stay
# in one group with multiple exporters and keep routing by per-exporter auth.
def _arize_dest(project, space="S", key="K"):
return OtelDestination(
endpoint="https://otlp.arize.com/v1",
headers={"space_id": space, "api_key": key},
callback_name="arize",
resource_attributes={"model_id": project, "arize.project.name": project},
)
def _provider_project(provider):
return provider.resource.attributes.get("arize.project.name")
def test_tracers_for_empty_returns_default_only():
cache = _cache("arize")
default = NoOpTracer()
assert cache.tracers_for(default, ()) == (default,)
assert cache._providers == {}
def test_tracers_for_single_destination_one_group():
"""No regression: a single Arize destination yields one tracer/provider carrying
its project (same as the old single-merged path)."""
cache = _cache("arize")
tracers = cache.tracers_for(NoOpTracer(), (_arize_dest("solo"),))
assert len(tracers) == 1
assert {_provider_project(p) for p in cache._providers.values()} == {"solo"}
def test_tracers_for_two_arize_projects_split_into_separate_groups():
"""The fix: two Arize destinations with different Resource attrs must NOT
last-wins merge -- each project gets its own provider/Resource so each receives a
correctly-tagged gen-AI span."""
cache = _cache("arize")
tracers = cache.tracers_for(
NoOpTracer(), (_arize_dest("projA"), _arize_dest("projB"))
)
assert len(tracers) == 2 # one tracer per project group
assert {_provider_project(p) for p in cache._providers.values()} == {
"projA",
"projB",
}
def test_two_arize_projects_each_provider_has_its_own_single_project():
"""Each group's Resource carries exactly its own project (not the other's, not a
merge)."""
cache = _cache("arize")
cache.tracers_for(NoOpTracer(), (_arize_dest("projA"), _arize_dest("projB")))
by_project = {
_provider_project(p): p.resource.attributes for p in cache._providers.values()
}
assert by_project["projA"]["model_id"] == "projA"
assert by_project["projB"]["model_id"] == "projB"
def test_header_routed_destinations_stay_one_group_with_two_exporters():
"""Langfuse/Weave declare no Resource attrs, so two distinct destinations collapse
into ONE group (one provider) with one exporter each -- they route by per-exporter
auth, so no per-Resource split is needed or wanted."""
cache = _cache(
"langfuse_otel",
exporters=[
ExporterSpec(kind="otlp_http", endpoint="https://env/v1", owner=None)
],
)
tracers = cache.tracers_for(
NoOpTracer(),
(_dest("https://a/v1", "Basic A"), _dest("https://b/v1", "Basic B")),
)
assert len(tracers) == 1 # single empty-Resource group
assert len(cache._providers) == 1
(provider,) = cache._providers.values()
endpoints = " ".join(
str(getattr(getattr(sp, "span_exporter", None), "_endpoint", ""))
for sp in provider._active_span_processor._span_processors
)
# global + both destinations all live on the one provider (OTLP normalizes the
# endpoint by appending /v1/traces, so match on the host+path prefix)
assert "https://a/v1" in endpoints and "https://b/v1" in endpoints
def test_base_exporters_attach_to_first_group_only():
"""When a backend splits into multiple Resource groups, the configured/global
exporters must ride exactly ONE group, so the global receives the gen-AI span once
rather than once per project."""
cache = _cache(
"arize",
exporters=[ExporterSpec(kind="in_memory", endpoint=None, owner=None)],
)
cache.tracers_for(NoOpTracer(), (_arize_dest("projA"), _arize_dest("projB")))
base_counts = {}
for provider in cache._providers.values():
project = _provider_project(provider)
base_counts[project] = sum(
type(getattr(sp, "span_exporter", sp)).__name__ == "InMemorySpanExporter"
for sp in provider._active_span_processor._span_processors
)
# exactly one group carries the global in_memory exporter; the other carries none
assert sorted(base_counts.values()) == [0, 1]
def test_generic_backend_resolves_generic_destination():
"""A Generic OTLP destination (callback_name='generic') must be picked up by the
generic OpenTelemetryV2 logger, so its gen-AI span routes to the destination's
otel_endpoint. Regression: 'generic' had no preset, so no generic logger existed and
the gen-AI span was dropped (only proxy-internal spans fanned out)."""
from litellm.integrations.otel.logger import OpenTelemetryV2
event = _event(
[
{
"callback_name": "generic",
"endpoint": "http://collector:4318",
"headers": {"x-tenant": "t1"},
},
{
"callback_name": "arize",
"endpoint": "https://otlp.arize.com/v1",
"headers": {"space_id": "S"},
},
]
)
class _Shim:
callback_name = "generic"
got = OpenTelemetryV2._destinations_for_backend(_Shim(), event)
assert [d.endpoint for d in got] == ["http://collector:4318"]

View file

@ -0,0 +1,348 @@
"""Tests for the per-request tenant fan-out SpanProcessor (Hoist).
The fan-out processor lives on the main v2 ``TracerProvider`` and forwards every
finished span to the admin-resolved per-tenant destinations carried on the
request's ``ContextVar``. The contract under test:
- Spans on the main provider land at every per-tenant destination matching this
backend (the ``owner_callback_name``).
- When destinations is empty (an unassigned identity, the SDK path, a request
before the resolver ran), the processor is a no-op.
- Concurrent requests with different destinations stay isolated -- contextvars
scope per task, so one request's tenant doesn't receive another's spans.
- The processor caches one BatchSpanProcessor per ``(endpoint, headers)`` pair
and skips destinations whose ``callback_name`` doesn't match its owner.
"""
import asyncio
from typing import Any
import pytest
pytest.importorskip("opentelemetry")
from opentelemetry.sdk.trace import TracerProvider # noqa: E402
from opentelemetry.sdk.trace.export import SimpleSpanProcessor # noqa: E402
from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402
InMemorySpanExporter,
)
from opentelemetry.sdk.trace.export import SpanExporter # noqa: E402
from litellm.integrations.otel.model.destination import OtelDestination # noqa: E402
from litellm.integrations.otel.plumbing.context import ( # noqa: E402
set_request_destinations,
_request_destinations,
)
from litellm.integrations.otel.plumbing.fan_out import ( # noqa: E402
TenantFanOutSpanProcessor,
_processor_key,
)
from litellm.integrations.otel.plumbing.providers import ( # noqa: E402
destination_resource_attrs,
)
from litellm.integrations.otel.plumbing import providers # noqa: E402
from litellm.integrations.otel.model.config import OpenTelemetryV2Config # noqa: E402
@pytest.fixture(autouse=True)
def _reset_request_destinations():
_request_destinations.set(())
yield
_request_destinations.set(())
def _build_provider_with_fan_out(
owner: str, exporter: SpanExporter
) -> tuple[TracerProvider, TenantFanOutSpanProcessor]:
"""Build a real TracerProvider with the in-memory exporter as the configured
backend, plus the fan-out processor wired in. The fan-out processor's
per-destination processors are real BatchSpanProcessors, replaced in the
test by an injected SimpleSpanProcessor against an in-memory exporter via
``monkeypatch`` so the test can read what was forwarded."""
cfg = OpenTelemetryV2Config(exporter="in_memory")
provider = providers.build_tracer_provider(
cfg, exporter=exporter, tenant_fan_out_owner=owner
)
fan_out = next(
p
for p in provider._active_span_processor._span_processors
if isinstance(p, TenantFanOutSpanProcessor)
)
return provider, fan_out
def test_fan_out_forwards_span_to_matching_destination(monkeypatch):
"""A span on the main provider lands at the per-tenant destination whose
callback_name matches the fan-out owner. Removing the fan-out branch makes
this test fail (the tenant exporter never sees the span)."""
main_exporter = InMemorySpanExporter()
provider, fan_out = _build_provider_with_fan_out("langfuse_otel", main_exporter)
tenant_exporter = InMemorySpanExporter()
# Swap the lazy per-destination processor build for an in-memory one so we
# don't actually hit the network.
def _stub_processor_for(self, destination):
return SimpleSpanProcessor(tenant_exporter)
monkeypatch.setattr(
TenantFanOutSpanProcessor, "_processor_for", _stub_processor_for
)
set_request_destinations(
(
OtelDestination(
callback_name="langfuse_otel",
endpoint="https://cloud.langfuse.com/api/public/otel",
headers={"Authorization": "Bearer pk:sk"},
),
)
)
tracer = provider.get_tracer("test")
with tracer.start_as_current_span("auth /chat/completions"):
pass
main_names = [s.name for s in main_exporter.get_finished_spans()]
tenant_names = [s.name for s in tenant_exporter.get_finished_spans()]
assert "auth /chat/completions" in main_names
assert "auth /chat/completions" in tenant_names
def test_fan_out_forwards_proxy_internal_spans_to_every_destination(monkeypatch):
"""Proxy-internal spans (server, auth phase, postgres, post-call ledger)
are generic OTel semconv with no backend-specific vocabulary, so they ship
to every admin-resolved destination regardless of its ``callback_name``.
The owner discriminator is only used to skip the gen-AI span (the v2 logger
routes that itself through TenantTracerCache to avoid wrong-vocabulary
duplicates)."""
main_exporter = InMemorySpanExporter()
_provider, _ = _build_provider_with_fan_out("langfuse_otel", main_exporter)
tenant_exporter = InMemorySpanExporter()
monkeypatch.setattr(
TenantFanOutSpanProcessor,
"_processor_for",
lambda self, destination: SimpleSpanProcessor(tenant_exporter),
)
# Destination is tagged for a DIFFERENT backend (``arize``) than the
# owner (``langfuse_otel``). Proxy-internal spans must still forward.
set_request_destinations(
(
OtelDestination(
callback_name="arize",
endpoint="https://otlp.arize.com/v1",
headers={"api_key": "k", "space_id": "s"},
),
)
)
tracer = _provider.get_tracer("test")
with tracer.start_as_current_span("auth"):
pass
names = [s.name for s in tenant_exporter.get_finished_spans()]
assert "auth" in names
def test_destination_resource_attrs_reads_declared_attrs(monkeypatch):
"""``destination_resource_attrs`` is backend-agnostic: it returns exactly what
the destination's builder declared, never consulting env itself. The env-vs-
credential precedence is enforced at build time (see test_presets_destinations),
so setting ARIZE_PROJECT_NAME here must NOT override the destination's own attrs."""
monkeypatch.setenv("ARIZE_PROJECT_NAME", "global-project")
destination = OtelDestination(
callback_name="arize",
endpoint="https://otlp.arize.com/v1",
headers={"api_key": "k", "space_id": "s"},
resource_attributes={
"model_id": "tenant-project",
"arize.project.name": "tenant-project",
},
)
assert destination_resource_attrs(destination) == {
"model_id": "tenant-project",
"arize.project.name": "tenant-project",
}
def test_destination_resource_attrs_empty_for_header_routed_backend():
"""A langfuse/weave-style destination routes by header and declares no Resource
attrs; the helper returns {} and the fan-out leaves the span Resource untouched."""
destination = OtelDestination(
callback_name="langfuse_otel",
endpoint="https://cloud.langfuse.com/api/public/otel",
headers={"Authorization": "Basic x"},
)
assert destination_resource_attrs(destination) == {}
def test_fan_out_skips_genai_span_to_avoid_double_export(monkeypatch):
"""The gen-AI LLM-call span is routed by the v2 logger through
``TenantTracerCache`` to per-tenant exporters with the right attribute
mapper. Forwarding it here too would deliver a duplicate with the wrong
vocabulary, surfacing in the destination as an orphaned second span."""
main_exporter = InMemorySpanExporter()
_provider, _ = _build_provider_with_fan_out("langfuse_otel", main_exporter)
tenant_exporter = InMemorySpanExporter()
monkeypatch.setattr(
TenantFanOutSpanProcessor,
"_processor_for",
lambda self, destination: SimpleSpanProcessor(tenant_exporter),
)
set_request_destinations(
(
OtelDestination(
callback_name="arize",
endpoint="https://otlp.arize.com/v1",
headers={},
),
)
)
tracer = _provider.get_tracer("test")
# A gen-AI span sets ``gen_ai.operation.name`` (the v2 emitter does this
# via the SpanRole.LLM_CALL mapper). Fake it explicitly here.
with tracer.start_as_current_span("chat gpt-4o") as span:
span.set_attribute("gen_ai.operation.name", "chat")
# A proxy-internal span also fires.
with tracer.start_as_current_span("auth /v1/chat/completions"):
pass
names = [s.name for s in tenant_exporter.get_finished_spans()]
assert "auth /v1/chat/completions" in names
assert "chat gpt-4o" not in names # gen-AI skipped
def test_fan_out_noop_when_no_destinations(monkeypatch):
"""An unassigned identity / pre-auth / SDK path leaves the contextvar at
its empty-tuple default; the processor must short-circuit, NOT crash, NOT
forward."""
main_exporter = InMemorySpanExporter()
provider, _ = _build_provider_with_fan_out("langfuse_otel", main_exporter)
forwarded: list[Any] = []
monkeypatch.setattr(
TenantFanOutSpanProcessor,
"_processor_for",
lambda self, destination: forwarded.append(destination) or None,
)
tracer = provider.get_tracer("test")
with tracer.start_as_current_span("auth"):
pass
assert forwarded == []
def test_fan_out_caches_processor_per_destination_key(monkeypatch):
"""Two requests with the same destination must share one cached
BatchSpanProcessor; two different destinations must build two. Otherwise
every request rebuilds the OTLP exporter (and its background thread)."""
main_exporter = InMemorySpanExporter()
_provider, fan_out = _build_provider_with_fan_out("langfuse_otel", main_exporter)
built: list[OtelDestination] = []
real_processor_for = TenantFanOutSpanProcessor._processor_for
def _spy(self, destination):
result = real_processor_for(self, destination)
if result is not None:
built.append(destination)
return result
# Build always returns None to keep the test offline, but we tracked
# invocations via the spy above. Easier: stub the inner builder.
def _stub(self, destination):
built.append(destination)
key = _processor_key(destination)
if key in self._processors:
return self._processors[key]
proc = SimpleSpanProcessor(InMemorySpanExporter())
self._processors[key] = proc
return proc
monkeypatch.setattr(TenantFanOutSpanProcessor, "_processor_for", _stub)
dest_a = OtelDestination(
callback_name="langfuse_otel", endpoint="https://a/", headers={"k": "1"}
)
dest_b = OtelDestination(
callback_name="langfuse_otel", endpoint="https://b/", headers={"k": "1"}
)
set_request_destinations((dest_a,))
tracer = _provider.get_tracer("test")
with tracer.start_as_current_span("s1"):
pass
set_request_destinations((dest_a,))
with tracer.start_as_current_span("s2"):
pass
set_request_destinations((dest_b,))
with tracer.start_as_current_span("s3"):
pass
# _stub appends every call; the cache size is what matters: two unique
# ``(endpoint, headers)`` pairs -> two cached processors.
assert len(fan_out._processors) == 2
def test_fan_out_per_request_isolation_with_concurrent_tasks(monkeypatch):
"""Two requests running concurrently with different destinations must each
see only THEIR destination's spans. The contextvar scopes per asyncio task,
so this is a pin on the contextvar approach: switching to a global variable
would break this test."""
main_exporter = InMemorySpanExporter()
provider, _ = _build_provider_with_fan_out("langfuse_otel", main_exporter)
exporter_a = InMemorySpanExporter()
exporter_b = InMemorySpanExporter()
def _stub(self, destination):
if destination.endpoint == "https://a/":
return SimpleSpanProcessor(exporter_a)
return SimpleSpanProcessor(exporter_b)
monkeypatch.setattr(TenantFanOutSpanProcessor, "_processor_for", _stub)
tracer = provider.get_tracer("test")
async def fire(label: str, endpoint: str):
set_request_destinations(
(
OtelDestination(
callback_name="langfuse_otel",
endpoint=endpoint,
headers={},
),
)
)
with tracer.start_as_current_span(label):
await asyncio.sleep(0)
async def driver():
await asyncio.gather(
fire("req_a", "https://a/"),
fire("req_b", "https://b/"),
)
asyncio.run(driver())
names_a = {s.name for s in exporter_a.get_finished_spans()}
names_b = {s.name for s in exporter_b.get_finished_spans()}
assert "req_a" in names_a and "req_b" not in names_a
assert "req_b" in names_b and "req_a" not in names_b
def test_fan_out_owner_set_by_build_tracer_provider():
"""The v2 logger opts the MAIN provider into fan-out by passing its
callback_name; the TenantTracerCache clone providers do NOT, so the gen-AI
span emitted through them is not also forwarded by the fan-out processor.
Regressing this (adding the processor to clones) would double-export every
gen-AI span."""
from litellm.integrations.otel.logger import OpenTelemetryV2
logger = OpenTelemetryV2(callback_name="arize")
main_procs = logger._tracer_provider._active_span_processor._span_processors
assert any(isinstance(p, TenantFanOutSpanProcessor) for p in main_procs)
# Build a tenant clone provider and confirm it has no fan-out processor.
clone = providers.build_tracer_provider(logger.config)
clone_procs = clone._active_span_processor._span_processors
assert not any(isinstance(p, TenantFanOutSpanProcessor) for p in clone_procs)

View file

@ -478,7 +478,7 @@ def test_live_llm_span_anchors_to_root_with_no_active_span():
set_request_root_span(server)
kwargs = _kwargs()
logger.log_pre_api_call(model="gpt-4o", messages=[], kwargs=kwargs)
assert logger._open_llm_calls["call_1"].span is not None # live, via anchor
assert logger._open_llm_calls["call_1"].spans # live, via anchor
asyncio.run(logger.async_log_success_event(kwargs, None, None, None))
server.end()
by_name = {s.name: s for s in exporter.get_finished_spans()}
@ -498,7 +498,7 @@ def test_deferred_llm_span_reads_anchor_at_close():
kwargs = _kwargs()
# pre_call with NO anchor and no active span → deferred.
logger.log_pre_api_call(model="gpt-4o", messages=[], kwargs=kwargs)
assert logger._open_llm_calls["call_1"].span is None # deferred
assert logger._open_llm_calls["call_1"].spans == () # deferred
# Anchor becomes visible at close (worker copied the request task's context).
set_request_root_span(server)
asyncio.run(logger.async_log_success_event(kwargs, None, None, None))
@ -538,6 +538,163 @@ def test_synthetic_error_log_produces_no_llm_span():
assert "auth /chat/completions" in names # auth span itself still recorded
def test_lazy_activation_emits_llm_span_when_destination_resolves(monkeypatch):
"""LIT-3850 lazy-activation seam: a v2 instance born inside the success path
(because the destination resolver appended its backend to ``success_callback``
on this request) was not in the callback list when ``pre_call`` iterated, so
no carrier was opened. The close must still emit the gen-ai span when the
payload is present and the admin-resolved destinations name this backend, so
the per-tenant exporter ships it. Without the fallthrough, this test sees
zero spans."""
logger, exporter = _logger()
monkeypatch.setattr(logger, "callback_name", "in_memory")
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
set_request_root_span(server)
# Make ``tracers_for`` return the logger's default tracer regardless of the
# destinations passed: the test asserts the close-path emitted the span, not
# that the per-destination provider clone wired up an OTLP exporter (the
# routing cache's job, covered separately). The default tracer is bound to
# the in-memory exporter so the test can read the result.
tracer_for_calls: list[tuple] = []
def _fake_tracers_for(default, destinations):
tracer_for_calls.append(destinations)
return (default,)
monkeypatch.setattr(logger._tenant_tracers, "tracers_for", _fake_tracers_for)
kwargs = _kwargs()
kwargs["standard_callback_dynamic_params"] = {
"otel_destinations": [
{
"callback_name": "in_memory",
"endpoint": "https://otlp.example.com/v1",
"headers": {"api_key": "k"},
}
]
}
assert "call_1" not in logger._open_llm_calls # no carrier opened
asyncio.run(logger.async_log_success_event(kwargs, None, None, None))
server.end()
names = [s.name for s in exporter.get_finished_spans()]
assert "chat gpt-4o" in names
# ``tracers_for`` was invoked with exactly the resolved destination, proving
# the deferred path used per-tenant routing rather than the default tracer
# blindly.
assert len(tracer_for_calls) == 1
(dests,) = tracer_for_calls
assert len(dests) == 1 and dests[0].endpoint == "https://otlp.example.com/v1"
def test_second_close_after_opened_call_does_not_emit_duplicate(monkeypatch):
logger, exporter = _logger()
monkeypatch.setattr(logger, "callback_name", "in_memory")
monkeypatch.setattr(
logger._tenant_tracers,
"tracers_for",
lambda default, destinations: (default,),
)
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
set_request_root_span(server)
kwargs = _kwargs()
kwargs["standard_callback_dynamic_params"] = {
"otel_destinations": [
{
"callback_name": "in_memory",
"endpoint": "https://otlp.example.com/v1",
"headers": {"api_key": "k"},
}
]
}
logger.log_pre_api_call(model="gpt-4o", messages=[], kwargs=kwargs)
asyncio.run(logger.async_log_success_event(kwargs, None, None, None))
asyncio.run(logger.async_log_failure_event(kwargs, None, None, None))
server.end()
names = [s.name for s in exporter.get_finished_spans()]
assert names.count("chat gpt-4o") == 1
def test_close_without_carrier_and_without_destination_drops_silently():
"""The pre-existing early-return semantics (auth gate / pre-call guardrail
rejection with no destination resolving to this backend) must be preserved:
no phantom span. The fix only widens emit-on-close when the admin-resolved
destinations name this backend AND the payload exists."""
logger, exporter = _logger()
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
set_request_root_span(server)
asyncio.run(logger.async_log_success_event(_kwargs(), None, None, None))
server.end()
names = [s.name for s in exporter.get_finished_spans()]
assert "chat gpt-4o" not in names
def test_close_without_carrier_drops_when_payload_missing(monkeypatch):
"""No carrier + no payload = the auth-gate rejection case (no upstream call
happened). Must drop even when destinations resolve, so a phantom span is
never emitted for a request the gate refused."""
logger, exporter = _logger()
monkeypatch.setattr(logger, "callback_name", "in_memory")
kwargs = {
"litellm_params": {"metadata": {}},
"standard_callback_dynamic_params": {
"otel_destinations": [
{
"callback_name": "in_memory",
"endpoint": "https://otlp.example.com/v1",
"headers": {},
}
]
},
}
asyncio.run(logger.async_log_success_event(kwargs, None, None, None))
assert exporter.get_finished_spans() == ()
def test_close_dedupes_duplicate_callbacks(monkeypatch):
"""Cursor BugBot regression: a normal close pops the carrier and emits
the LLM span; a second callback for the same call_id must not emit a
duplicate. Before the dedup guard, the second close hit the
carrier-is-None branch and fired _emit_deferred_llm_call again whenever
payload + destinations remained on the kwargs, double-exporting the
span (e.g. success + failure callbacks both firing, or a custom callback
fanning out)."""
logger, exporter = _logger()
monkeypatch.setattr(logger, "callback_name", "in_memory")
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
set_request_root_span(server)
monkeypatch.setattr(
logger._tenant_tracers, "tracers_for", lambda default, dests: (default,)
)
kwargs = _kwargs()
kwargs["standard_callback_dynamic_params"] = {
"otel_destinations": [
{
"callback_name": "in_memory",
"endpoint": "https://otlp.example.com/v1",
"headers": {},
}
]
}
logger.log_pre_api_call(model="gpt-4o", messages=[], kwargs=kwargs)
asyncio.run(logger.async_log_success_event(kwargs, None, None, None))
# Second callback for the same call_id: payload + destinations still on
# kwargs, but no second span must emit.
asyncio.run(logger.async_log_success_event(kwargs, None, None, None))
asyncio.run(logger.async_log_failure_event(kwargs, None, None, None))
server.end()
llm_spans = [s for s in exporter.get_finished_spans() if s.name == "chat gpt-4o"]
assert len(llm_spans) == 1
def test_create_request_started_span_captures_anchor():
"""``create_litellm_proxy_request_started_span`` doubles as the anchor capture
point: the active server span becomes the request root for later spans."""
@ -1580,3 +1737,139 @@ def test_metrics_disabled_by_default_records_nothing(monkeypatch):
)
)
assert _emitted_metric_names(reader) == set()
def _second_group_tracer(logger):
"""A second independent in-memory provider standing in for a second Resource group
(e.g. a second Arize project); returns (tracer, exporter)."""
exporter = InMemorySpanExporter()
provider = providers.build_tracer_provider(logger.config, exporter=exporter)
return provider.get_tracer("litellm"), exporter
def test_genai_span_emitted_to_every_group_live(monkeypatch):
"""Multi-destination fix (live path): when ``tracers_for`` returns two tracers (two
Resource groups, e.g. two Arize projects), the gen-AI span opened at ``pre_call``
must be opened+finished on BOTH -- the bug was only one project receiving it."""
logger, exporter_a = _logger()
monkeypatch.setattr(logger, "callback_name", "in_memory")
tracer_b, exporter_b = _second_group_tracer(logger)
monkeypatch.setattr(
logger._tenant_tracers,
"tracers_for",
lambda default, dests: (default, tracer_b),
)
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
set_request_root_span(server)
kwargs = _kwargs()
kwargs["standard_callback_dynamic_params"] = {
"otel_destinations": [
{"callback_name": "in_memory", "endpoint": "https://x/v1", "headers": {}}
]
}
_emit_llm(logger, kwargs, ambient=server)
server.end()
assert [s.name for s in exporter_a.get_finished_spans()].count("chat gpt-4o") == 1
assert [s.name for s in exporter_b.get_finished_spans()].count("chat gpt-4o") == 1
def test_genai_span_emitted_to_every_group_deferred(monkeypatch):
"""Same fix, deferred path (no carrier at ``pre_call``): ``emit_fanout`` dedups once
on the call id then emits the span on every group's tracer, so both projects get
exactly one."""
logger, exporter_a = _logger()
monkeypatch.setattr(logger, "callback_name", "in_memory")
tracer_b, exporter_b = _second_group_tracer(logger)
monkeypatch.setattr(
logger._tenant_tracers,
"tracers_for",
lambda default, dests: (default, tracer_b),
)
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
set_request_root_span(server)
kwargs = _kwargs()
kwargs["standard_callback_dynamic_params"] = {
"otel_destinations": [
{"callback_name": "in_memory", "endpoint": "https://x/v1", "headers": {}}
]
}
# no pre_call -> no carrier -> deferred close path
assert "call_1" not in logger._open_llm_calls
asyncio.run(logger.async_log_success_event(kwargs, None, None, None))
server.end()
assert [s.name for s in exporter_a.get_finished_spans()].count("chat gpt-4o") == 1
assert [s.name for s in exporter_b.get_finished_spans()].count("chat gpt-4o") == 1
def _generic_dest():
return {
"callback_name": "generic",
"endpoint": "http://collector:4318",
"headers": {},
}
def test_generic_destination_emits_genai_span(monkeypatch):
"""Acceptance #1: a request whose only admin destination is a Generic OTLP
destination emits the chat <model> gen-AI span (regression: 'generic' had no preset,
so the gen-AI span was dropped and only proxy-internal spans reached the endpoint).
The destination's callback_name='generic' is matched by the generic logger, and the
span is routed through the per-destination tracer."""
logger, exporter = _logger()
monkeypatch.setattr(logger, "callback_name", "generic")
routed: list = []
monkeypatch.setattr(
logger._tenant_tracers,
"tracers_for",
lambda default, dests: (routed.append(dests) or (default,)),
)
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
set_request_root_span(server)
kwargs = _kwargs()
kwargs["standard_callback_dynamic_params"] = {
"otel_destinations": [_generic_dest()]
}
_emit_llm(logger, kwargs, ambient=server)
server.end()
assert "chat gpt-4o" in [s.name for s in exporter.get_finished_spans()]
# the gen-AI span was routed for the generic destination (not an empty/global set)
assert any(
len(d) == 1 and d[0].endpoint == "http://collector:4318" for d in routed
), "generic destination was not routed to the generic logger's tracer"
def test_generic_destination_emits_error_span_on_failure(monkeypatch):
"""Acceptance #3: a FAILED call to a Generic OTLP destination still emits the
chat <model> span, with OTEL status ERROR and the exception type."""
logger, exporter = _logger()
monkeypatch.setattr(logger, "callback_name", "generic")
monkeypatch.setattr(
logger._tenant_tracers, "tracers_for", lambda default, dests: (default,)
)
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
set_request_root_span(server)
payload = _payload(
status="failure",
error_information={
"error_class": "AuthenticationError",
"error_message": "Incorrect API key",
},
)
kwargs = _kwargs(payload=payload)
kwargs["standard_callback_dynamic_params"] = {
"otel_destinations": [_generic_dest()]
}
_emit_llm(logger, kwargs, ambient=server, fail=True)
server.end()
spans = {s.name: s for s in exporter.get_finished_spans()}
assert "chat gpt-4o" in spans
assert spans["chat gpt-4o"].status.status_code is StatusCode.ERROR
assert spans["chat gpt-4o"].attributes["error.type"] == "AuthenticationError"

View file

@ -87,3 +87,33 @@ def test_config_folds_legacy_exporter_triple_into_exporters_list():
assert len(cfg.exporters) == 1
assert cfg.exporters[0].kind == "otlp_http"
assert cfg.exporters[0].endpoint == "https://api.example.com"
def _processor_names(provider):
return [type(p).__name__ for p in provider._active_span_processor._span_processors]
def test_main_logger_provider_always_carries_fan_out():
# The FastAPI server span (and auth/db/cost spans) bind to the main v2 logger's
# provider. That provider MUST carry the TenantFanOutSpanProcessor even for the
# generic global logger (no backend named) -- otherwise, for an admin-owned-
# destination-only deployment, the server span never reaches the destination and
# the gen-AI child is orphaned. Regression for the orphaned-span RCA.
from litellm.integrations.otel.logger import OpenTelemetryV2
generic = OpenTelemetryV2()
assert "TenantFanOutSpanProcessor" in _processor_names(generic._tracer_provider)
named = OpenTelemetryV2(callback_name="arize")
assert "TenantFanOutSpanProcessor" in _processor_names(named._tracer_provider)
def test_build_tracer_provider_attach_fan_out_flag():
cfg = OpenTelemetryV2Config(exporter="console")
# Per-tenant clone providers pass neither flag and must NOT fan out (else the
# gen-AI span they carry would be double-exported).
clone = build_tracer_provider(cfg)
assert "TenantFanOutSpanProcessor" not in _processor_names(clone)
# The main provider opts in explicitly.
main = build_tracer_provider(cfg, attach_tenant_fan_out=True)
assert "TenantFanOutSpanProcessor" in _processor_names(main)

View file

@ -33,27 +33,72 @@ def test_agentops_preset_does_no_network_io(monkeypatch):
assert spec.options == {"api_key": "ak-123"} # carried to the lazy exporter
def test_agentops_preset_without_key_omits_options(monkeypatch):
def test_agentops_preset_without_key_omits_exporter(monkeypatch):
# With no API key the lazy-auth exporter has nothing to mint a JWT from, so
# the preset must not contribute a global exporter at all (it would otherwise
# fail every export). Admin-owned destinations carry their own credentials.
monkeypatch.delenv("AGENTOPS_API_KEY", raising=False)
cfg = agentops_preset()
spec = next(e for e in cfg.exporters if e.kind == _AGENTOPS_EXPORTER_KIND)
assert spec.options is None
assert [e for e in cfg.exporters if e.kind == _AGENTOPS_EXPORTER_KIND] == []
def test_arize_preset_without_credentials_omits_exporter(monkeypatch):
# Arize's OTLP ingestion rejects unauthenticated exports (PERMISSION_DENIED),
# so with no Arize credentials the preset must not contribute a credential-less
# global exporter pointed at the Arize cloud.
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.presets.arize import arize_preset
for var in (
"ARIZE_SPACE_ID",
"ARIZE_SPACE_KEY",
"ARIZE_API_KEY",
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
):
monkeypatch.delenv(var, raising=False)
cfg = arize_preset()
assert [e for e in cfg.exporters if e.owner == ExporterOwner.ARIZE_AX] == []
monkeypatch.setenv("ARIZE_SPACE_ID", "S")
monkeypatch.setenv("ARIZE_API_KEY", "K")
cfg = arize_preset()
assert [e for e in cfg.exporters if e.owner == ExporterOwner.ARIZE_AX] != []
def test_phoenix_preset_without_config_omits_exporter(monkeypatch):
# Unconfigured Phoenix defaults to http://localhost:6006; the preset must not
# contribute that exporter unless Phoenix is actually configured (cloud key or
# collector endpoint), so admin-owned-only setups don't export to localhost.
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.presets.phoenix import (
_PHOENIX_ENV_VARS,
phoenix_preset,
)
for var in _PHOENIX_ENV_VARS:
monkeypatch.delenv(var, raising=False)
cfg = phoenix_preset()
assert [e for e in cfg.exporters if e.owner == ExporterOwner.ARIZE_PHOENIX] == []
monkeypatch.setenv("PHOENIX_API_KEY", "px-key")
cfg = phoenix_preset()
assert [e for e in cfg.exporters if e.owner == ExporterOwner.ARIZE_PHOENIX] != []
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.presets.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 +110,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, (
@ -160,3 +205,83 @@ def test_agentops_endpoint_points_at_live_host():
# so a typo or stale domain can never ship again.
assert _AGENTOPS_ENDPOINT == "https://otlp.agentops.ai/v1/traces"
assert "agentops.cloud" not in _AGENTOPS_ENDPOINT
def test_credential_mandatory_presets_raise_without_creds_by_default(monkeypatch):
# weave/langfuse/levo are credential-mandatory: with no global env creds and no
# opt-in to degrade, the preset must RAISE so a misconfigured global callback
# (e.g. ``callbacks: ["weave_otel"]`` with no keys) fails loud at startup, the
# same error story as before V2 landed. _maybe_construct_otel_v2 relies on this
# raise to defer to the legacy path.
from litellm.integrations.otel.presets.langfuse import langfuse_preset
from litellm.integrations.otel.presets.levo import levo_preset
from litellm.integrations.otel.presets.weave import weave_preset
for var in (
"WANDB_API_KEY",
"WANDB_PROJECT_ID",
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LEVOAI_API_KEY",
):
monkeypatch.delenv(var, raising=False)
for preset in (weave_preset, langfuse_preset, levo_preset):
with pytest.raises(Exception):
preset()
def test_credential_mandatory_presets_degrade_when_allowed(monkeypatch):
# When an admin-owned destination is the reason for construction it carries its
# own per-tenant credentials, so the preset is called with
# allow_missing_credentials=True and must degrade to an exporter-less config
# (keeping its mappers) instead of raising -- otherwise the destination never gets
# a v2 logger and its gen-AI span falls to the generic global logger.
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.presets.langfuse import langfuse_preset
from litellm.integrations.otel.presets.levo import levo_preset
from litellm.integrations.otel.presets.weave import weave_preset
for var in (
"WANDB_API_KEY",
"WANDB_PROJECT_ID",
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LEVOAI_API_KEY",
):
monkeypatch.delenv(var, raising=False)
for preset, owner in (
(weave_preset, ExporterOwner.WEAVE_OTEL),
(langfuse_preset, ExporterOwner.LANGFUSE_OTEL),
(levo_preset, ExporterOwner.LEVO),
):
cfg = preset(allow_missing_credentials=True) # must not raise
assert [e for e in cfg.exporters if e.owner == owner] == []
def test_weave_preset_with_creds_contributes_exporter(monkeypatch):
from litellm.integrations.otel.model.config import ExporterOwner
from litellm.integrations.otel.presets.weave import weave_preset
monkeypatch.setenv("WANDB_API_KEY", "w-key")
monkeypatch.setenv("WANDB_PROJECT_ID", "entity/project")
cfg = weave_preset()
assert [e for e in cfg.exporters if e.owner == ExporterOwner.WEAVE_OTEL] != []
def test_generic_preset_needs_no_global_env_and_emits_genai(monkeypatch):
# Acceptance #2: the generic preset is vendor-neutral and admin-destination-only --
# it must build with NO global OTEL env vars, never raise, carry the standard genai
# (+legacy) mappers, and contribute NO vendor exporter (the per-destination exporter
# is appended by the router).
from litellm.integrations.otel.presets.generic import generic_preset
for var in ("OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_ENDPOINT", "OTEL_EXPORTER"):
monkeypatch.delenv(var, raising=False)
cfg = generic_preset() # must not raise, no env needed
assert "genai" in cfg.mapper_names
# no vendor (owned) exporter contributed -- only the base/global passthrough, if any
assert all(e.owner is None for e in cfg.exporters)
# the degrade flag is accepted (Preset protocol) and irrelevant -- still builds
assert generic_preset(allow_missing_credentials=True) is not None

View file

@ -0,0 +1,212 @@
"""``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.presets.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(monkeypatch):
monkeypatch.delenv("ARIZE_PROJECT_NAME", raising=False)
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"}
# --- per-backend Resource declaration -------------------------------------- #
#
# Each backend declares the Resource attributes its ingestion needs, in its own
# builder. Arize is the only first-class backend that routes by a Resource
# attribute (``model_id``); langfuse / weave / generic route by auth header and
# declare none. The shared ``destination_resource_attrs`` just reads whatever the
# builder put on the destination, so the model generalizes: a new backend that
# needs Resource-level routing only populates ``resource_attributes`` here.
def test_arize_project_from_credential_sets_resource_attrs(monkeypatch):
monkeypatch.delenv("ARIZE_PROJECT_NAME", raising=False)
dest = build_destination(
"arize",
{"arize_space_id": "S", "arize_api_key": "K", "arize_project_name": "team-x"},
)
assert dest is not None
assert dest.resource_attributes == {
"model_id": "team-x",
"arize.project.name": "team-x",
}
def test_arize_project_falls_back_to_env(monkeypatch):
monkeypatch.setenv("ARIZE_PROJECT_NAME", "env-proj")
dest = build_destination("arize", {"arize_space_id": "S", "arize_api_key": "K"})
assert dest is not None
assert dest.resource_attributes == {
"model_id": "env-proj",
"arize.project.name": "env-proj",
}
def test_arize_credential_project_wins_over_env(monkeypatch):
monkeypatch.setenv("ARIZE_PROJECT_NAME", "env-proj")
dest = build_destination(
"arize",
{
"arize_space_id": "S",
"arize_api_key": "K",
"arize_project_name": "cred-proj",
},
)
assert dest is not None
assert dest.resource_attributes["model_id"] == "cred-proj"
def test_arize_no_project_anywhere_has_empty_resource_attrs(monkeypatch):
monkeypatch.delenv("ARIZE_PROJECT_NAME", raising=False)
dest = build_destination("arize", {"arize_space_id": "S", "arize_api_key": "K"})
assert dest is not None
assert dest.resource_attributes == {}
def test_header_routed_backends_declare_no_resource_attrs():
"""langfuse / weave / generic route the project via auth headers, so they
declare no Resource attributes -- the generalization counterpart to arize."""
langfuse = build_destination(
"langfuse_otel",
{"langfuse_public_key": "pk", "langfuse_secret_key": "sk"},
)
weave = build_destination(
"weave_otel",
{
"wandb_api_key": "w",
"weave_endpoint": "https://trace.wandb.ai/otel/v1/traces",
},
)
generic = build_destination(
"self_hosted", {"otel_endpoint": "https://collector:4318/v1/traces"}
)
for dest in (langfuse, weave, generic):
assert dest is not None
assert dest.resource_attributes == {}
def test_weave_requires_only_api_key_and_defaults_endpoint():
# No API key -> nothing.
assert build_destination("weave_otel", {}) is None
# The API key alone is enough: Weave cloud's endpoint is fixed, so it defaults
# to the cloud OTLP path and the endpoint field is optional.
dest = build_destination(
"weave_otel",
{"wandb_api_key": "w", "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"}
)
def test_endpoint_whitespace_is_trimmed():
# A stray leading/trailing space in the endpoint (an easy create-form slip)
# makes a malformed OTLP URL the exporter rejects with a 404, so values are
# trimmed before the destination is built.
dest = build_destination(
"some_collector",
{"otel_endpoint": " https://collector.internal:4318/v1/traces "},
)
assert dest is not None
assert dest.endpoint == "https://collector.internal:4318/v1/traces"
def test_weave_endpoint_completed_to_otel_path():
# Weave's OTLP path is /otel/v1/traces, not the bare /v1/traces the generic
# exporter would append; a host must be completed here or the export 404s.
# Idempotent when the full path or the /otel prefix is already supplied.
for given, expected in (
("https://trace.wandb.ai", "https://trace.wandb.ai/otel/v1/traces"),
("https://trace.wandb.ai/otel", "https://trace.wandb.ai/otel/v1/traces"),
(
"https://trace.wandb.ai/otel/v1/traces",
"https://trace.wandb.ai/otel/v1/traces",
),
):
dest = build_destination(
"weave_otel", {"wandb_api_key": "w", "weave_endpoint": given}
)
assert dest is not None
assert dest.endpoint == expected

View file

@ -1928,12 +1928,21 @@ class TestOpenTelemetryEndpointNormalization(unittest.TestCase):
"https://example.com/prefix/v2/trace/otlp",
"https://example.com/prefix/v2/trace/otlp",
),
(
"https://app.langtrace.ai/api/trace",
"https://app.langtrace.ai/api/trace",
),
(
"https://app.langtrace.ai/api/trace/",
"https://app.langtrace.ai/api/trace",
),
]
)
def test_normalize_traces_nonstandard_otlp_ingest_urls_unchanged(
self, input_url: str, expected: str
) -> None:
"""Splunk-style /v2/trace/otlp endpoints must not get /v1/traces appended."""
"""Vendor full-path ingest URLs (Splunk /v2/trace/otlp, Langtrace /api/trace)
must not get /v1/traces appended that 404s."""
otel = OpenTelemetry()
self.assertEqual(
otel._normalize_otel_endpoint(input_url, "traces"),
@ -5852,3 +5861,45 @@ class TestOpenTelemetryMetricAttributeFiltering(unittest.TestCase):
exporter="console", attributes=attributes
)
)
class LangtraceV1ConfigTest(unittest.TestCase):
def test_langtrace_uses_live_host_and_x_api_key_header(self):
"""Regression: litellm hardcoded the stale https://langtrace.ai/api/trace (now 404)
with header ``api_key=``. The live ingest is https://app.langtrace.ai/api/trace and
the auth header is ``x-api-key``. Pins both so the stale values can't return."""
import litellm
import litellm.litellm_core_utils.litellm_logging as ll
from litellm.integrations.opentelemetry import OpenTelemetry
from litellm.integrations.otel.model.config import is_otel_v2_enabled
saved = {
k: os.environ.get(k)
for k in (
"LANGTRACE_API_KEY",
"LITELLM_OTEL_V2",
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
)
}
try:
ll._in_memory_loggers.clear() # force a fresh build, not a cached logger
os.environ["LANGTRACE_API_KEY"] = "lt-test"
os.environ.pop("LITELLM_OTEL_V2", None)
is_otel_v2_enabled.cache_clear()
litellm.credential_list = []
logger = ll._init_custom_logger_compatible_class("langtrace", None, None)
assert isinstance(logger, OpenTelemetry)
self.assertEqual(
logger.config.endpoint, "https://app.langtrace.ai/api/trace"
)
self.assertEqual(
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"], "x-api-key=lt-test"
)
finally:
is_otel_v2_enabled.cache_clear()
ll._in_memory_loggers.clear()
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value

View file

@ -36,6 +36,58 @@ def test_resolves_plain_values_from_metadata():
assert params.get("langfuse_host") == "https://test.langfuse.com"
def test_otel_destinations_read_from_litellm_metadata_only():
"""The admin-resolved OTEL destinations are carried server-side under
``litellm_metadata`` (a known internal key, scrubbed from the provider body) --
NOT as a top-level kwarg, since an unknown top-level key would leak to providers.
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(
{"litellm_metadata": {"otel_destinations": destinations}}
)
assert params.get("otel_destinations") == destinations
def test_otel_destinations_top_level_kwarg_is_ignored():
"""A top-level ``otel_destinations`` kwarg is intentionally NOT read. The proxy
stashes admin-resolved destinations under ``litellm_metadata`` to keep unknown
keys out of the body forwarded to the provider; reading the top-level key would
re-open that surface and is therefore ignored."""
params = initialize_standard_callback_dynamic_params(
{"otel_destinations": [{"callback_name": "langfuse_otel"}]}
)
assert params.get("otel_destinations") is None
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

@ -3532,9 +3532,9 @@ async def test_streaming_anthropic_messages_openai_bridge_fires_success_logging(
await _drain_until_logged(logger)
assert chunks, "stream yielded no chunks"
assert any("content_block_delta" in _chunk_text(c) for c in chunks), (
"no delta chunks surfaced; the streaming text deltas were not forwarded"
)
assert any(
"content_block_delta" in _chunk_text(c) for c in chunks
), "no delta chunks surfaced; the streaming text deltas were not forwarded"
assert logger.success_payload is not None, (
"async_log_success_event never fired for streaming /v1/messages -> openai "
"Responses bridge; the no-op stream path dropped the spend row"
@ -3585,3 +3585,123 @@ def test_failure_handler_zeroes_spend_without_recovered_usage(logging_obj):
assert payload["status"] == "failure"
assert payload["response_cost"] == 0
assert payload["total_tokens"] == 0
def test_admin_owned_destination_uses_otel_v2_without_global_flag(monkeypatch):
# An admin-owned logging destination (a "logging" credential, created from the
# UI) must make the backend resolve to the OTEL v2 logger even when
# LITELLM_OTEL_V2 is off, so the per-destination credentials drive the export.
# Otherwise activation falls back to the legacy global logger, which ignores
# the destination's credentials and exports with absent global env creds.
from types import SimpleNamespace
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
is_otel_v2_enabled.cache_clear()
assert is_otel_v2_enabled() is False
# No logging credential for the backend -> legacy fallback (None).
monkeypatch.setattr(litellm, "credential_list", [])
assert _maybe_construct_otel_v2("arize", []) is None
# A logging destination registered for the backend -> v2 owns it.
monkeypatch.setattr(
litellm,
"credential_list",
[
SimpleNamespace(
credential_name="arize-poc",
credential_info={
"credential_type": "logging",
"description": "arize",
},
)
],
)
logger = _maybe_construct_otel_v2("arize", [])
is_otel_v2_enabled.cache_clear()
assert isinstance(logger, OpenTelemetryV2)
assert logger.callback_name == "arize"
def test_credential_mandatory_backend_global_misconfig_stays_loud(monkeypatch):
# Regression: weave/langfuse/levo are credential-mandatory; before V2 a global
# callback (e.g. ``callbacks: ["weave_otel"]``) with no credentials failed loud at
# startup. _maybe_construct_otel_v2 must preserve that: with V2 on but no admin-owned
# destination and no creds, the preset raises, _maybe_construct returns None, and the
# caller falls through to the legacy path that re-raises. An admin-owned destination,
# by contrast, carries its own per-tenant creds, so the same missing-global-creds
# state must degrade to a working v2 logger instead of silently producing nothing.
from types import SimpleNamespace
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2
for var in ("WANDB_API_KEY", "WANDB_PROJECT_ID"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
# Global callback, no destination, no creds -> None so the caller fails loud.
monkeypatch.setattr(litellm, "credential_list", [])
assert _maybe_construct_otel_v2("weave_otel", []) is None
# Admin-owned weave destination present -> degrade to a working v2 logger.
monkeypatch.setattr(
litellm,
"credential_list",
[
SimpleNamespace(
credential_name="wb-poc",
credential_info={
"credential_type": "logging",
"description": "weave_otel",
},
)
],
)
logger = _maybe_construct_otel_v2("weave_otel", [])
is_otel_v2_enabled.cache_clear()
assert isinstance(logger, OpenTelemetryV2)
assert logger.callback_name == "weave_otel"
def test_generic_admin_destination_builds_otel_v2_logger(monkeypatch):
# The Generic OTLP passthrough ('generic') must build an OpenTelemetryV2 logger when
# an admin-owned generic destination is registered (even with LITELLM_OTEL_V2 off),
# so the gen-AI span routes to the destination's otel_endpoint. Without a generic
# destination and without the global flag, it stays None (no generic logger).
from types import SimpleNamespace
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
is_otel_v2_enabled.cache_clear()
monkeypatch.setattr(litellm, "credential_list", [])
assert _maybe_construct_otel_v2("generic", []) is None
monkeypatch.setattr(
litellm,
"credential_list",
[
SimpleNamespace(
credential_name="ui-generic",
credential_info={
"credential_type": "logging",
"description": "generic",
},
)
],
)
logger = _maybe_construct_otel_v2("generic", [])
is_otel_v2_enabled.cache_clear()
assert isinstance(logger, OpenTelemetryV2)
assert logger.callback_name == "generic"

View file

@ -3788,6 +3788,37 @@ async def test_builder_succeeds_when_db_lookup_returns_valid_token():
mock_return.assert_awaited_once()
@pytest.mark.asyncio
async def test_builder_hoists_destinations_before_post_lookup_auth_checks():
valid_token = UserAPIKeyAuth(api_key="sk-db-lookup-test", token="hashed-valid")
get_key_object = AsyncMock(return_value=valid_token)
async def _assert_hoisted_first(*args, **kwargs):
assert mock_hoist.await_count == 1
with (
patch(
"litellm.proxy.auth.user_api_key_auth._return_user_api_key_auth_obj",
new_callable=AsyncMock,
return_value=valid_token,
),
patch(
"litellm.proxy.auth.user_api_key_auth._hoist_request_destinations",
new_callable=AsyncMock,
) as mock_hoist,
patch(
"litellm.proxy.auth.user_api_key_auth._enforce_key_and_fallback_model_access",
new_callable=AsyncMock,
side_effect=_assert_hoisted_first,
) as mock_enforce,
):
result = await _run_builder_with_key_lookup(get_key_object)
assert result is valid_token
mock_hoist.assert_awaited_once()
mock_enforce.assert_awaited_once()
def _mint_cli_session_token(monkeypatch, *, user_id="cli-admin"):
"""Mint a CLI session token for a PROXY_ADMIN user so auth resolves on the
admin early-return path (no prisma/common_checks needed)."""
@ -3987,7 +4018,9 @@ async def test_non_admin_cli_session_token_reaches_production_auth_path(monkeypa
assert call_kwargs["valid_token_dict"]["user_id"] == "internal-user-1"
assert call_kwargs["valid_token_dict"]["team_id"] == "team-abc"
assert call_kwargs["valid_token_dict"]["is_session_token"] is True
assert call_kwargs["valid_token_dict"]["user_role"] == LitellmUserRoles.INTERNAL_USER
assert (
call_kwargs["valid_token_dict"]["user_role"] == LitellmUserRoles.INTERNAL_USER
)
assert result.is_session_token is True

View file

@ -0,0 +1,314 @@
"""Exhaustive tests for the pure access-decision function.
Each test names one specific reason a team-admin patch should be denied (or
allowed). Together they pin the security contract: changing this code with
the tests in place should fail a case named after what you broke.
"""
from __future__ import annotations
import pytest
from litellm.models.credentials import CredentialInfo
from litellm.proxy.credential_endpoints.access_decision import (
Allow,
Deny,
decide_credential_patch,
)
_EXISTING_INFO = {
"credential_type": "logging",
"description": "tenant Langfuse",
"host": "https://cloud.langfuse.com",
"access": {"teams": ["team-A", "team-B"], "orgs": ["org-1"], "global": False},
}
def _info(value):
return None if value is None else CredentialInfo.model_validate(value)
def _decision(
*,
is_proxy_admin: bool = False,
caller_team_admin_ids: frozenset[str] = frozenset({"team-T"}),
existing_info=_EXISTING_INFO,
patch_info=None,
patch_values=None,
patch_name_changed: bool = False,
):
return decide_credential_patch(
is_proxy_admin=is_proxy_admin,
caller_team_admin_ids=caller_team_admin_ids,
existing_info=_info(existing_info),
patch_info=_info(patch_info),
patch_values=patch_values,
patch_name_changed=patch_name_changed,
)
class TestProxyAdminAllow:
def test_proxy_admin_allowed_on_value_change(self):
d = _decision(
is_proxy_admin=True,
patch_values={"api_key": "rotated"},
patch_info={"credential_type": "logging"},
)
assert isinstance(d, Allow)
def test_proxy_admin_allowed_on_global_flip(self):
d = _decision(
is_proxy_admin=True,
patch_info={"access": {"global": True}},
)
assert isinstance(d, Allow)
def test_proxy_admin_allowed_on_rename(self):
d = _decision(is_proxy_admin=True, patch_name_changed=True)
assert isinstance(d, Allow)
class TestTeamAdminAllow:
def test_appending_own_team_id(self):
d = _decision(
patch_info={
"access": {"teams": ["team-A", "team-B", "team-T"]},
},
)
assert isinstance(d, Allow)
def test_appending_only_own_team_id_when_no_prior_teams(self):
existing = {**_EXISTING_INFO, "access": {"global": False}}
d = _decision(
existing_info=existing,
patch_info={"access": {"teams": ["team-T"]}},
)
assert isinstance(d, Allow)
def test_idempotent_when_already_granted(self):
existing = {**_EXISTING_INFO, "access": {"teams": ["team-T"]}}
d = _decision(
existing_info=existing,
patch_info={"access": {"teams": ["team-T"]}},
)
assert isinstance(d, Allow)
class TestTeamAdminDeny:
def test_not_team_admin_anywhere(self):
d = _decision(
caller_team_admin_ids=frozenset(),
patch_info={"access": {"teams": ["team-T"]}},
)
assert isinstance(d, Deny)
assert "proxy admin" in d.reason
def test_rename(self):
d = _decision(
patch_name_changed=True,
patch_info={"access": {"teams": ["team-T"]}},
)
assert isinstance(d, Deny)
assert "credential_name" in d.reason
def test_changing_credential_values(self):
d = _decision(
patch_values={"api_key": "stolen"},
patch_info={"access": {"teams": ["team-T"]}},
)
assert isinstance(d, Deny)
assert "credential_values" in d.reason
def test_empty_patch(self):
d = _decision(patch_info=None)
assert isinstance(d, Deny)
@pytest.mark.parametrize(
"field", ["credential_type", "description", "host", "endpoint"]
)
def test_changing_immutable_info_field(self, field):
d = _decision(patch_info={field: "x", "access": {"teams": ["team-T"]}})
assert isinstance(d, Deny)
assert field in d.reason
def test_patch_info_with_unknown_keys(self):
d = _decision(patch_info={"weird_field": 1})
assert isinstance(d, Deny)
assert "weird_field" in d.reason
def test_flipping_global(self):
d = _decision(patch_info={"access": {"global": True}})
assert isinstance(d, Deny)
assert "global" in d.reason
def test_editing_orgs(self):
d = _decision(patch_info={"access": {"orgs": ["org-new"]}})
assert isinstance(d, Deny)
assert "orgs" in d.reason
def test_no_op_resend_of_existing_global_is_allowed(self):
"""The UI's Edit-access modal always sends the FULL access object
(so unchecking a team revokes). A non-admin re-sending the existing
global=false alongside their team patch must NOT be rejected as if
they were trying to flip the toggle.
Before this fix the decider checked only "is global_ in the patch?"
which broke every UI save that included the unchanged global toggle
plus a team edit.
"""
d = _decision(
patch_info={
"access": {
"global": False,
"teams": ["team-A", "team-B", "team-T"],
"orgs": ["org-1"],
}
}
)
assert isinstance(d, Allow)
def test_no_op_resend_of_existing_orgs_is_allowed(self):
"""Same shape as global: a patch that includes the unchanged orgs
list alongside a team edit must pass."""
d = _decision(
patch_info={
"access": {
"global": False,
"teams": ["team-A", "team-B", "team-T"],
"orgs": ["org-1"],
}
}
)
assert isinstance(d, Allow)
def test_attempt_to_flip_global_when_existing_is_false(self):
"""Direct flip from stored False to True still rejected."""
d = _decision(
patch_info={"access": {"global": True, "teams": ["team-A", "team-B"]}}
)
assert isinstance(d, Deny)
assert "global" in d.reason
def test_attempt_to_flip_global_when_existing_is_true(self):
"""Direct flip from stored True to False still rejected (it's still
a global mutation; only proxy-admin can change destination-wide reach)."""
existing = {
**_EXISTING_INFO,
"access": {**_EXISTING_INFO["access"], "global": True},
}
d = _decision(
existing_info=existing,
patch_info={"access": {"global": False, "teams": ["team-A", "team-B"]}},
)
assert isinstance(d, Deny)
assert "global" in d.reason
def test_attempt_to_change_orgs_is_rejected(self):
"""Adding an org_id different from stored is still rejected."""
d = _decision(
patch_info={
"access": {"orgs": ["org-1", "org-2"], "teams": ["team-A", "team-B"]}
}
)
assert isinstance(d, Deny)
assert "orgs" in d.reason
def test_adding_foreign_team_id(self):
"""foreign team_ids in the patch ARE caller input -- safe to echo."""
d = _decision(
patch_info={
"access": {"teams": ["team-A", "team-B", "team-foreign"]},
},
)
assert isinstance(d, Deny)
assert d.from_user_input is True
assert "team-foreign" in d.reason
def test_removing_foreign_team_grant(self):
"""team-admin may NOT remove a team they don't admin.
Reason intentionally does NOT echo the stored team_id (it's not
caller-typed; surfacing it would leak access list membership).
"""
d = _decision(
patch_info={
"access": {"teams": ["team-A", "team-T"]},
},
)
assert isinstance(d, Deny)
assert d.from_user_input is False
assert "team-B" not in d.reason
assert "may only revoke" in d.reason
def test_replacing_teams_wholesale_with_foreign_remaining(self):
"""Wholesale replacement that removes foreign grants is rejected.
The stored team_ids that were dropped must not appear in the reason --
they're access list contents, not caller input.
"""
d = _decision(patch_info={"access": {"teams": ["team-T"]}})
assert isinstance(d, Deny)
assert d.from_user_input is False
assert "team-A" not in d.reason
assert "team-B" not in d.reason
class TestTeamAdminRevoke:
"""A team-admin may revoke their OWN team's grant; never another's."""
def test_revoking_own_team_is_allowed(self):
existing = {
**_EXISTING_INFO,
"access": {"teams": ["team-A", "team-T"]},
}
d = _decision(
existing_info=existing,
patch_info={"access": {"teams": ["team-A"]}},
)
assert isinstance(d, Allow)
def test_revoking_own_team_when_only_grant(self):
"""Saving an empty teams list when the caller was the sole grant."""
existing = {**_EXISTING_INFO, "access": {"teams": ["team-T"]}}
d = _decision(
existing_info=existing,
patch_info={"access": {"teams": []}},
)
assert isinstance(d, Allow)
def test_revoke_attempt_on_foreign_team_denied(self):
"""A patch that removes a foreign team is still rejected, even if
the caller is also revoking their own. The foreign team_id MUST
NOT appear in the reason (stored access list content)."""
existing = {
**_EXISTING_INFO,
"access": {"teams": ["team-A", "team-B", "team-T"]},
}
d = _decision(
existing_info=existing,
patch_info={"access": {"teams": ["team-A"]}}, # drops team-B AND team-T
)
assert isinstance(d, Deny)
assert d.from_user_input is False
assert "team-B" not in d.reason
class TestTeamAdminMultipleTeams:
def test_can_add_multiple_own_team_ids(self):
d = _decision(
caller_team_admin_ids=frozenset({"team-T1", "team-T2"}),
patch_info={
"access": {"teams": ["team-A", "team-B", "team-T1", "team-T2"]},
},
)
assert isinstance(d, Allow)
def test_one_own_one_foreign_is_deny(self):
d = _decision(
caller_team_admin_ids=frozenset({"team-T1"}),
patch_info={
"access": {"teams": ["team-A", "team-B", "team-T1", "team-T2"]},
},
)
assert isinstance(d, Deny)
assert "team-T2" in d.reason

View file

@ -0,0 +1,764 @@
"""Admin-gating on credential mutations.
POST and DELETE on ``/credentials`` are proxy-admin only across the board
(both logging and provider credentials). The route gate was widened so
team-admins can PATCH ``access.teams`` on existing logging destinations,
which requires also reaching the GET endpoint but creation and deletion
of any credential remain admin-only to keep platform infrastructure under
the platform admin's control. PATCH is widened for logging credentials only
via the pure ``decide_credential_patch`` decider tested separately.
"""
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, UpdateCredentialItem
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.credential_endpoints.access_decision import OPAQUE_DENY_REASON
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_forbidden_for_non_admin(_connected_db):
"""POST is proxy-admin only even for provider credentials.
The route gate now lets non-admins reach the credentials path so they can
PATCH ``access.teams`` on logging destinations they should be able to
self-assign. POST remains admin-only to keep credential creation a
platform-admin concern.
"""
with pytest.raises(HTTPException) as exc:
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 exc.value.status_code == 403
_connected_db.create.assert_not_awaited()
@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},
}
def test_update_db_credential_preserves_untouched_access_subfields():
"""Veria regression: an access patch carrying only `teams` must NOT clobber
existing `global` / `orgs`. Pre-fix this caused a scope-tampering bug
the decider only allowed team-admin patches that touched access.teams,
but the merge replaced the entire access object, silently dropping
access.global=true and any access.orgs entries.
"""
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",
"access": {
"global": True,
"orgs": ["org-1", "org-2"],
"teams": ["team-A"],
},
},
)
# Team-admin's allowed shape: add their team to access.teams. Crucially,
# they don't (and per the decider can't) include global/orgs.
patch = CredentialItem(
credential_name="dest",
credential_values={},
credential_info={"access": {"teams": ["team-A", "team-T"]}},
)
merged = update_db_credential(db, patch)
# access.global and access.orgs survive untouched; access.teams is updated.
assert merged.credential_info["access"] == {
"global": True,
"orgs": ["org-1", "org-2"],
"teams": ["team-A", "team-T"],
}
@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()
@pytest.mark.asyncio
async def test_update_db_only_logging_credential_forbidden_for_non_admin(
_connected_db, monkeypatch
):
"""A logging credential that exists ONLY in the DB (not resident in the
in-memory ``credential_list`` -- e.g. created on another scaled instance or
before a restart) must still gate a non-admin update. The gate falls back to
the DB so a credential_values-only patch can't redirect a logging
destination's endpoint without the proxy-admin check."""
monkeypatch.setattr(litellm, "credential_list", []) # nothing in memory
_connected_db.find_by_name = AsyncMock(
return_value=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
@pytest.mark.asyncio
async def test_delete_db_only_logging_credential_forbidden_for_non_admin(
_connected_db, monkeypatch
):
"""Same DB-only fallback for delete: a non-admin can't delete a logging
credential that is resident only in the DB."""
monkeypatch.setattr(litellm, "credential_list", [])
_connected_db.find_by_name = AsyncMock(
return_value=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()
# --- team-admin self-assign tests (LIT-3850 follow-up) ----------------------
_DEST_WITH_TEAMS = {
"credential_type": "logging",
"description": "tenant Langfuse",
"host": "https://cloud.langfuse.com",
"access": {"teams": ["team-existing"]},
}
def _team_admin_of(team_ids):
"""A non-admin caller whose user_id is admin of the named teams.
Combined with ``_patch_team_admin_lookup`` it mimics the real
``_caller_grantable_team_ids`` resolution without touching the DB.
"""
return UserAPIKeyAuth(
api_key="k", user_role=LitellmUserRoles.INTERNAL_USER, user_id="ta-demo"
)
@pytest.fixture
def _patch_team_admin_lookup(monkeypatch):
"""Substitute the DB-backed grant-capability lookup with a configurable mock."""
holder = {"ids": frozenset()}
async def _fake(user_api_key_dict, prisma_client):
return holder["ids"]
monkeypatch.setattr(endpoints, "_caller_grantable_team_ids", _fake)
return holder
def _resident_logging_dest():
return CredentialItem(
credential_name="dest",
credential_values={"langfuse_host": "h"},
credential_info=_DEST_WITH_TEAMS,
)
@pytest.mark.asyncio
async def test_team_admin_can_append_own_team_to_access(
_connected_db, _patch_team_admin_lookup, monkeypatch
):
monkeypatch.setattr(litellm, "credential_list", [_resident_logging_dest()])
_connected_db.find_by_name = AsyncMock(return_value=_resident_logging_dest())
_connected_db.update_by_name = AsyncMock()
_patch_team_admin_lookup["ids"] = frozenset({"team-T"})
result = await endpoints.update_credential(
request=MagicMock(),
fastapi_response=MagicMock(),
credential=CredentialItem(
credential_name="dest",
credential_values={},
credential_info={"access": {"teams": ["team-existing", "team-T"]}},
),
credential_name="dest",
user_api_key_dict=_team_admin_of(["team-T"]),
)
assert result["success"] is True
_connected_db.update_by_name.assert_awaited_once()
@pytest.mark.asyncio
async def test_provider_credential_patch_forbidden_for_non_admin(
_connected_db, monkeypatch
):
"""A team-admin (or any non-admin) cannot PATCH a non-logging credential.
The route gate was widened to let team-admins reach /credentials/{name}
for logging-credential access edits. A provider credential is not
is_admin_gated_credential_info, so the decider block is skipped; without
an explicit else-branch a team-admin could rotate the upstream api_key.
"""
provider_cred = CredentialItem(
credential_name="openai-prod",
credential_values={"api_key": "sk-real"},
credential_info={"custom_llm_provider": "openai"},
)
monkeypatch.setattr(litellm, "credential_list", [provider_cred])
_connected_db.find_by_name = AsyncMock(return_value=provider_cred)
_connected_db.update_by_name = AsyncMock()
with pytest.raises(HTTPException) as exc:
await endpoints.update_credential(
request=MagicMock(),
fastapi_response=MagicMock(),
credential=CredentialItem(
credential_name="openai-prod",
credential_values={"api_key": "sk-stolen"},
credential_info={},
),
credential_name="openai-prod",
user_api_key_dict=_member(),
)
assert exc.value.status_code == 403
_connected_db.update_by_name.assert_not_awaited()
@pytest.mark.asyncio
async def test_provider_credential_access_patch_bypass_forbidden(
_connected_db, _patch_team_admin_lookup, monkeypatch
):
"""Cursor BugBot regression: a team-admin can't sneak `access.teams` onto
a PROVIDER credential to route through the decider instead of the admin
gate.
`is_admin_gated_credential_info(patch)` returns True for any patch
containing an `access` field, so previously a team-admin could PATCH a
provider credential with `{credential_info: {access: {teams: [...]}}}`
and reach `decide_credential_patch`, which would Allow because the patch
is just "add own team to access.teams". Gate must look at the STORED
credential's type, not the patch body.
"""
provider_cred = CredentialItem(
credential_name="openai-prod",
credential_values={"api_key": "sk-real"},
credential_info={"custom_llm_provider": "openai"},
)
monkeypatch.setattr(litellm, "credential_list", [provider_cred])
_connected_db.find_by_name = AsyncMock(return_value=provider_cred)
_connected_db.update_by_name = AsyncMock()
_patch_team_admin_lookup["ids"] = frozenset({"team-T"})
with pytest.raises(HTTPException) as exc:
await endpoints.update_credential(
request=MagicMock(),
fastapi_response=MagicMock(),
credential=UpdateCredentialItem(
credential_info={"access": {"teams": ["team-T"]}},
),
credential_name="openai-prod",
user_api_key_dict=_team_admin_of(["team-T"]),
)
assert exc.value.status_code == 403
_connected_db.update_by_name.assert_not_awaited()
@pytest.mark.asyncio
async def test_team_admin_can_revoke_own_team_grant(
_connected_db, _patch_team_admin_lookup, monkeypatch
):
"""A team-admin saving an access list without their own team_id revokes it."""
existing = CredentialItem(
credential_name="dest",
credential_values={"langfuse_host": "h"},
credential_info={
"credential_type": "logging",
"description": "tenant Langfuse",
"host": "https://cloud.langfuse.com",
"access": {"teams": ["team-existing", "team-T"]},
},
)
monkeypatch.setattr(litellm, "credential_list", [existing])
_connected_db.find_by_name = AsyncMock(return_value=existing)
_connected_db.update_by_name = AsyncMock()
_patch_team_admin_lookup["ids"] = frozenset({"team-T"})
result = await endpoints.update_credential(
request=MagicMock(),
fastapi_response=MagicMock(),
credential=CredentialItem(
credential_name="dest",
credential_values={},
credential_info={"access": {"teams": ["team-existing"]}},
),
credential_name="dest",
user_api_key_dict=_team_admin_of(["team-T"]),
)
assert result["success"] is True
_connected_db.update_by_name.assert_awaited_once()
@pytest.mark.asyncio
async def test_team_admin_cannot_grant_foreign_team(
_connected_db, _patch_team_admin_lookup, monkeypatch
):
monkeypatch.setattr(litellm, "credential_list", [_resident_logging_dest()])
_patch_team_admin_lookup["ids"] = frozenset({"team-T"})
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": {"teams": ["team-existing", "team-foreign"]}
},
),
credential_name="dest",
user_api_key_dict=_team_admin_of(["team-T"]),
)
assert exc.value.status_code == 403
assert "team-foreign" in exc.value.detail["error"]
@pytest.mark.asyncio
async def test_team_admin_cannot_rotate_credential_values(
_connected_db, _patch_team_admin_lookup, monkeypatch
):
monkeypatch.setattr(litellm, "credential_list", [_resident_logging_dest()])
_patch_team_admin_lookup["ids"] = frozenset({"team-T"})
with pytest.raises(HTTPException) as exc:
await endpoints.update_credential(
request=MagicMock(),
fastapi_response=MagicMock(),
credential=CredentialItem(
credential_name="dest",
credential_values={"public_key": "pk-stolen"},
credential_info={"access": {"teams": ["team-existing", "team-T"]}},
),
credential_name="dest",
user_api_key_dict=_team_admin_of(["team-T"]),
)
assert exc.value.status_code == 403
# Endpoint collapses non-caller-input Deny reasons to the opaque message
# so PATCH /credentials/{name} can't be used as an existence oracle.
assert exc.value.detail["error"] == OPAQUE_DENY_REASON
@pytest.mark.asyncio
async def test_team_admin_cannot_flip_global(
_connected_db, _patch_team_admin_lookup, monkeypatch
):
monkeypatch.setattr(litellm, "credential_list", [_resident_logging_dest()])
_patch_team_admin_lookup["ids"] = frozenset({"team-T"})
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=_team_admin_of(["team-T"]),
)
assert exc.value.status_code == 403
assert exc.value.detail["error"] == OPAQUE_DENY_REASON
@pytest.mark.asyncio
async def test_get_credentials_filters_to_logging_for_non_admin(
monkeypatch, _patch_team_admin_lookup
):
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="openai",
credential_values={"api_key": "sk-secret"},
credential_info={"custom_llm_provider": "openai"},
),
CredentialItem(
credential_name="poc-langfuse",
credential_values={"public_key": "pk-1"},
credential_info=_DEST_WITH_TEAMS,
),
],
)
_patch_team_admin_lookup["ids"] = frozenset({"team-T"})
response = await endpoints.get_credentials(
request=MagicMock(),
fastapi_response=MagicMock(),
user_api_key_dict=_team_admin_of(["team-T"]),
)
names = [c["credential_name"] for c in response["credentials"]]
assert names == ["poc-langfuse"]
@pytest.mark.asyncio
async def test_get_credentials_forbidden_for_plain_user(
monkeypatch, _patch_team_admin_lookup
):
"""Veria F2 regression: a plain internal_user (no team-admin or
org-admin status anywhere) gets 403, NOT a filtered list. The previous
handler returned destination names, hosts, and scope metadata to any
authenticated caller because the route gate was widened to support
team-admin self-assignment.
"""
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="poc-langfuse",
credential_values={"public_key": "pk-1"},
credential_info=_DEST_WITH_TEAMS,
),
],
)
_patch_team_admin_lookup["ids"] = frozenset() # admins nothing
with pytest.raises(HTTPException) as exc:
await endpoints.get_credentials(
request=MagicMock(),
fastapi_response=MagicMock(),
user_api_key_dict=UserAPIKeyAuth(
api_key="k",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="plain-user",
),
)
assert exc.value.status_code == 403
assert "team-admin" in exc.value.detail["error"]
def test_patch_credentials_route_targets_update_credential():
"""Regression: the @router.patch decorator on /credentials/{name:path} must
decorate update_credential, not one of the extracted helpers. A misplaced
decorator landed once during the 7ecc1d49 split and the unit tests didn't
catch it because they import the handler function directly; this asserts
the FastAPI routing table actually points at update_credential.
"""
from fastapi.routing import APIRoute
patch_route = next(
route
for route in endpoints.router.routes
if isinstance(route, APIRoute)
and route.path == "/credentials/{credential_name:path}"
and "PATCH" in route.methods
)
assert patch_route.endpoint is endpoints.update_credential
@pytest.mark.asyncio
async def test_patch_credentials_does_not_leak_credential_type(
_connected_db, _patch_team_admin_lookup, monkeypatch
):
"""Existence-oracle regression: a team-admin probing a credential they don't own
must NOT be able to distinguish "logging credential, not yours" from
"provider credential" or "doesn't exist" by comparing 403 detail strings.
Pre-fix the decider returned reasons like "access.global is proxy-admin only"
while `_require_proxy_admin` returned a fixed string, so the same probe
(e.g. PATCH `{credential_info: {access: {global: true}}}`) would yield
different bodies depending on the stored type. All three paths now return
the same opaque message.
"""
provider = CredentialItem(
credential_name="openai-prod",
credential_values={"api_key": "sk-real"},
credential_info={"custom_llm_provider": "openai"},
)
logging_other = CredentialItem(
credential_name="other-langfuse",
credential_values={"langfuse_host": "h"},
credential_info={
"credential_type": "logging",
"description": "tenant Langfuse",
"host": "https://cloud.langfuse.com",
"access": {"teams": ["team-other"]},
},
)
monkeypatch.setattr(litellm, "credential_list", [provider, logging_other])
_connected_db.find_by_name = AsyncMock(
side_effect=lambda name: provider if name == "openai-prod" else logging_other
)
# Caller is team-admin of team-T (NOT team-other, so can't legitimately
# edit logging_other either), probing with a patch that touches a
# decider-protected field to maximally expose any branch divergence.
_patch_team_admin_lookup["ids"] = frozenset({"team-T"})
probe_patch = UpdateCredentialItem(
credential_info={"access": {"global": True}},
)
bodies: list[object] = []
for name in ("openai-prod", "other-langfuse", "does-not-exist"):
with pytest.raises(HTTPException) as exc:
await endpoints.update_credential(
request=MagicMock(),
fastapi_response=MagicMock(),
credential=probe_patch,
credential_name=name,
user_api_key_dict=_team_admin_of(["team-T"]),
)
assert exc.value.status_code == 403
bodies.append(exc.value.detail)
assert bodies[0] == bodies[1] == bodies[2] == {"error": OPAQUE_DENY_REASON}
@pytest.mark.asyncio
async def test_patch_credentials_echoes_foreign_team_id_to_legit_team_admin(
_connected_db, _patch_team_admin_lookup, monkeypatch
):
"""The one accepted leak: when a team-admin tries to grant a team_id they
typed in the patch and don't admin, the response names that team_id so
the UI can render a useful error. The team_id was caller input, so it
isn't an existence oracle (the caller already knew the value).
"""
monkeypatch.setattr(litellm, "credential_list", [_resident_logging_dest()])
_connected_db.find_by_name = AsyncMock(return_value=_resident_logging_dest())
_patch_team_admin_lookup["ids"] = frozenset({"team-T"})
with pytest.raises(HTTPException) as exc:
await endpoints.update_credential(
request=MagicMock(),
fastapi_response=MagicMock(),
credential=UpdateCredentialItem(
credential_info={
"access": {"teams": ["team-existing", "team-foreign"]}
},
),
credential_name="dest",
user_api_key_dict=_team_admin_of(["team-T"]),
)
assert exc.value.status_code == 403
assert "team-foreign" in exc.value.detail["error"]
@pytest.mark.asyncio
async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch):
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="openai",
credential_values={"api_key": "sk-secret"},
credential_info={"custom_llm_provider": "openai"},
),
CredentialItem(
credential_name="poc-langfuse",
credential_values={"public_key": "pk-1"},
credential_info=_DEST_WITH_TEAMS,
),
],
)
response = await endpoints.get_credentials(
request=MagicMock(),
fastapi_response=MagicMock(),
user_api_key_dict=_admin(),
)
names = sorted(c["credential_name"] for c in response["credentials"])
assert names == ["openai", "poc-langfuse"]

View file

@ -0,0 +1,385 @@
"""Validation for admin-owned logging-exporter assignment on key/team/org.
The single ``validate_logging_exporter_assignment`` runs across all four
endpoints (``/team/new``, ``/team/update``, ``/key/generate``, ``/key/update``,
``/organization/*``); each call site computes the relevant
``caller_is_team_admin`` / ``caller_is_org_admin`` flags from the loaded
team or org and passes them in. Proxy admin always passes.
"""
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 = [
# global: visible to (and assignable by) every scope.
CredentialItem(
credential_name="langfuse-eu",
credential_values={},
credential_info={
"credential_type": "logging",
"description": "langfuse_otel",
"access": {"global": True},
},
),
# scoped to one team / one org: assignable only within that scope.
CredentialItem(
credential_name="arize-ds",
credential_values={},
credential_info={
"credential_type": "logging",
"description": "arize",
"access": {"teams": ["ds-team"], "orgs": ["ds-org"]},
},
),
# explicit global/default: assignable by anyone via the auto_enable escape.
CredentialItem(
credential_name="central-default",
credential_values={},
credential_info={
"credential_type": "logging",
"description": "arize",
"auto_enable": True,
},
),
CredentialItem(
credential_name="openai-key",
credential_values={},
credential_info={"custom_llm_provider": "openai"}, # provider credential
),
]
try:
yield
finally:
litellm.credential_list = original
def _admin():
return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.PROXY_ADMIN)
def _non_admin():
return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.INTERNAL_USER)
def _ok(metadata):
return {"logging_exporters": metadata}
# --- Role allow paths -------------------------------------------------------
def test_proxy_admin_always_allowed(_registry):
"""No flags needed; proxy_admin role suffices."""
validate_logging_exporter_assignment(_ok(["langfuse-eu"]), _admin())
def test_team_admin_flag_allows_non_admin(_registry):
"""A non-admin caller flagged caller_is_team_admin=True passes."""
validate_logging_exporter_assignment(
_ok(["langfuse-eu"]),
_non_admin(),
caller_is_team_admin=True,
)
def test_org_admin_flag_allows_non_admin(_registry):
"""A non-admin caller flagged caller_is_org_admin=True passes."""
validate_logging_exporter_assignment(
_ok(["langfuse-eu"]),
_non_admin(),
caller_is_org_admin=True,
)
def test_both_flags_set_allows_non_admin(_registry):
"""Setting both flags is fine; they're independent OR-ed allows."""
validate_logging_exporter_assignment(
_ok(["langfuse-eu"]),
_non_admin(),
caller_is_team_admin=True,
caller_is_org_admin=True,
)
def test_non_admin_with_no_flags_is_forbidden(_registry):
"""The headline deny: internal_user with no team/org admin context."""
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_assignment(_ok(["langfuse-eu"]), _non_admin())
assert exc.value.status_code == 403
def test_proxy_admin_overrides_falsy_flags(_registry):
"""proxy_admin role wins even when both flags are False."""
validate_logging_exporter_assignment(
_ok(["langfuse-eu"]),
_admin(),
caller_is_team_admin=False,
caller_is_org_admin=False,
)
# --- Scope checks: a non-proxy-admin may only name destinations granted to them -
def test_team_admin_can_assign_destination_granted_to_their_team(_registry):
"""arize-ds is granted to ds-team; a team admin writing in ds-team's scope may
name it."""
validate_logging_exporter_assignment(
_ok(["arize-ds"]),
_non_admin(),
caller_is_team_admin=True,
scope_team_id="ds-team",
)
def test_team_admin_cannot_assign_destination_not_granted_to_their_team(_registry):
"""The headline leak: a team admin of another team names ds-team's destination.
Pre-fix this passed (only the name was checked); now it is a 403."""
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_assignment(
_ok(["arize-ds"]),
_non_admin(),
caller_is_team_admin=True,
scope_team_id="platform-team",
)
assert exc.value.status_code == 403
def test_org_admin_can_assign_destination_granted_to_their_org(_registry):
validate_logging_exporter_assignment(
_ok(["arize-ds"]),
_non_admin(),
caller_is_org_admin=True,
scope_org_id="ds-org",
)
def test_org_admin_cannot_assign_destination_not_granted_to_their_org(_registry):
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_assignment(
_ok(["arize-ds"]),
_non_admin(),
caller_is_org_admin=True,
scope_org_id="other-org",
)
assert exc.value.status_code == 403
def test_proxy_admin_can_assign_any_destination(_registry):
"""Proxy admin skips the scope check entirely; arize-ds is granted to no scope
the admin is in, yet the write is allowed."""
validate_logging_exporter_assignment(
_ok(["arize-ds"]),
_admin(),
scope_team_id="platform-team",
)
def test_team_admin_can_assign_auto_enable_default(_registry):
"""An explicit global/default (auto_enable) is assignable by any admin scope,
the way a global destination is."""
validate_logging_exporter_assignment(
_ok(["central-default"]),
_non_admin(),
caller_is_team_admin=True,
scope_team_id="platform-team",
)
def test_team_admin_can_assign_global_destination(_registry):
"""access.global makes a destination visible to every scope, so a team admin
in any team may name it."""
validate_logging_exporter_assignment(
_ok(["langfuse-eu"]),
_non_admin(),
caller_is_team_admin=True,
scope_team_id="platform-team",
)
# --- Shape / registry checks (run regardless of who's calling) --------------
def test_unknown_credential_rejected_for_admin(_registry):
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_assignment(_ok(["does-not-exist"]), _admin())
assert exc.value.status_code == 400
def test_unknown_credential_rejected_for_team_admin(_registry):
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_assignment(
_ok(["does-not-exist"]),
_non_admin(),
caller_is_team_admin=True,
)
assert exc.value.status_code == 400
def test_provider_credential_rejected(_registry):
"""openai-key exists but is provider-typed, not a logging destination."""
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_assignment(_ok(["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
def test_noop_when_field_absent(_registry):
"""An update that does not touch logging_exporters skips the gate even
for a non-admin with no flags."""
validate_logging_exporter_assignment({"some_other_key": 1}, _non_admin())
validate_logging_exporter_assignment(None, _non_admin())
# --- Veria F4: removal-via-omission ----------------------------------------
#
# Update endpoints replace stored metadata wholesale. A caller can wipe an
# admin-assigned `logging_exporters` by sending a `metadata` payload that
# omits the field. The validator must catch this when ``existing_metadata``
# is passed.
def test_removal_via_omission_blocked_for_non_admin(_registry):
"""A non-admin with no flags cannot wipe an admin-assigned exporter by
submitting metadata without logging_exporters."""
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_assignment(
{"some_other_key": 1}, # no logging_exporters in the new payload
_non_admin(),
existing_metadata={"logging_exporters": ["langfuse-eu"]},
)
assert exc.value.status_code == 403
def test_removal_via_omission_allowed_for_proxy_admin(_registry):
"""Proxy admin may drop the exporter via omission."""
validate_logging_exporter_assignment(
{"some_other_key": 1},
_admin(),
existing_metadata={"logging_exporters": ["langfuse-eu"]},
)
def test_removal_via_omission_allowed_for_team_admin(_registry):
"""A team-admin of the owning team may drop the exporter."""
validate_logging_exporter_assignment(
{"some_other_key": 1},
_non_admin(),
caller_is_team_admin=True,
existing_metadata={"logging_exporters": ["langfuse-eu"]},
)
def test_explicit_empty_list_blocked_for_non_admin(_registry):
"""A non-admin submitting `logging_exporters: []` over a non-empty stored
value is a removal write and must be gated."""
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_assignment(
{"logging_exporters": []},
_non_admin(),
existing_metadata={"logging_exporters": ["langfuse-eu"]},
)
assert exc.value.status_code == 403
def test_explicit_null_blocked_for_non_admin(_registry):
"""`logging_exporters: null` over a non-empty stored value is also a
removal; the validator's shape check would reject it as non-list, but
F4's authorization gate must fire first."""
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_assignment(
{"logging_exporters": None},
_non_admin(),
existing_metadata={"logging_exporters": ["langfuse-eu"]},
)
assert exc.value.status_code == 403
def test_unchanged_value_is_noop(_registry):
"""A metadata payload that re-sends the SAME logging_exporters value is
a noop and skips the gate even for a non-admin -- there is no net change
to authorize."""
validate_logging_exporter_assignment(
{"logging_exporters": ["langfuse-eu"]},
_non_admin(),
existing_metadata={"logging_exporters": ["langfuse-eu"]},
)
def test_omitted_on_both_sides_is_noop(_registry):
"""A metadata update that doesn't touch logging_exporters on a row that
never had one is a noop."""
validate_logging_exporter_assignment(
{"some_other_key": 1},
_non_admin(),
existing_metadata={"some_other_key": 0},
)
# --- is_admin_gated_credential_info / validate_credential_access ------------
@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,416 @@ 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",
"access": {"global": True},
},
),
CredentialItem(
credential_name="arize-prod",
credential_values={
"arize_space_id": "S",
"arize_api_key": "K",
"arize_project_name": "tenant-arize",
},
credential_info={
"credential_type": "logging",
"description": "arize",
"access": {"global": True},
},
),
# 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_carries_arize_project(
_seeded_logging_credentials,
):
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
destinations, _ = await _resolve_logging_exporters(
_auth(team_exporters=["arize-prod"])
)
assert destinations == [
{
"callback_name": "arize",
"endpoint": "https://otlp.arize.com/v1",
"headers": {"space_id": "S", "api_key": "K"},
"resource_attributes": {
"model_id": "tenant-arize",
"arize.project.name": "tenant-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.integrations.otel.plumbing.context import (
_request_destinations,
request_destinations,
)
from litellm.proxy.litellm_pre_call_utils import _apply_admin_logging_exporters
token = _request_destinations.set(())
data: dict = {}
try:
await _apply_admin_logging_exporters(
data, _auth(team_exporters=["langfuse-eu"])
)
# destinations live under litellm_metadata so the body does not leak an
# unknown top-level key to the provider; the top-level key stays absent
assert "otel_destinations" not in data
destinations = data["litellm_metadata"]["otel_destinations"]
assert destinations[0]["callback_name"] == "langfuse_otel"
assert (
destinations[0]["endpoint"] == "https://cloud.langfuse.com/api/public/otel"
)
# the backend is activated for the request
assert "langfuse_otel" in data["success_callback"]
context_destinations = request_destinations()
assert len(context_destinations) == 1
assert (
context_destinations[0].endpoint
== "https://cloud.langfuse.com/api/public/otel"
)
finally:
_request_destinations.reset(token)
@pytest.mark.asyncio
async def test_apply_admin_logging_exporters_registers_on_failure(
_seeded_logging_credentials,
):
"""An admin-owned destination must capture a FAILED upstream call, not only a
successful one.
Each resolved backend is a dynamic logging callback that fires per-request;
registering it on ``success_callback`` alone means a 401/timeout never reaches
the backend's failure callback, so the destination's trace lands with no
error gen-AI span. Both lists must carry every backend, deduped against any
pre-existing entry.
"""
from litellm.integrations.otel.plumbing.context import _request_destinations
from litellm.proxy.litellm_pre_call_utils import _apply_admin_logging_exporters
token = _request_destinations.set(())
# Seed a pre-existing failure callback to prove backends are unioned in, not
# overwriting, and that a duplicate backend is not appended twice.
data: dict = {"failure_callback": ["arize"]}
try:
await _apply_admin_logging_exporters(
data, _auth(team_exporters=["langfuse-eu", "arize-prod"])
)
for callback_list in ("success_callback", "failure_callback"):
registered = data[callback_list]
assert "langfuse_otel" in registered
assert "arize" in registered
assert registered.count("arize") == 1
finally:
_request_destinations.reset(token)
_LANGFUSE_ENDPOINT = "https://cloud.langfuse.com/api/public/otel"
_ARIZE_ENDPOINT = "https://otlp.arize.com/v1"
@pytest.fixture
def _seeded_logging_credentials_with_access():
"""``access`` is visibility, not enablement. ``langfuse-eu`` is granted to
``team-eu``/``org-eu`` but never auto-fires; ``arize-global`` carries
``access.global`` to prove global visibility alone STILL does not auto-fire;
``arize-default`` is the explicit ``auto_enable`` global/default."""
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},
},
),
CredentialItem(
credential_name="arize-default",
credential_values={"arize_space_id": "D", "arize_api_key": "K"},
credential_info={
"credential_type": "logging",
"description": "arize",
"auto_enable": True,
},
),
]
try:
yield
finally:
litellm.credential_list = original
@pytest.mark.asyncio
async def test_resolve_grant_does_not_auto_enable(
_seeded_logging_credentials_with_access,
):
"""Granting a destination to a team (or globally) must NOT enable it for the
team's requests. The pre-fix resolver fired on access alone; this pins that
access is now visibility-only. ``arize-default`` (auto_enable) is the only thing
that fires for an unassigned team-eu caller."""
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-eu"))
# langfuse-eu is granted to team-eu and arize-global is access.global, yet
# neither fires because neither is named; only the explicit auto_enable does.
assert {d["endpoint"] for d in destinations} == {_ARIZE_ENDPOINT}
@pytest.mark.asyncio
async def test_resolve_name_with_grant_enables(
_seeded_logging_credentials_with_access,
):
"""Naming a destination the caller is granted enables it (alongside the
auto_enable default)."""
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
destinations, _ = await _resolve_logging_exporters(
_auth(team_id="team-eu", team_exporters=["langfuse-eu"])
)
assert {d["endpoint"] for d in destinations} == {_LANGFUSE_ENDPOINT, _ARIZE_ENDPOINT}
@pytest.mark.asyncio
async def test_resolve_name_without_visibility_is_dropped(
_seeded_logging_credentials_with_access,
):
"""A name that points at a destination NOT visible to the request identity is
defensively ignored, so a stale or cross-tenant assignment can never route
traffic out. team-other names langfuse-eu (granted only to team-eu)."""
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
destinations, _ = await _resolve_logging_exporters(
_auth(team_id="team-other", team_exporters=["langfuse-eu"])
)
# langfuse-eu dropped (not visible to team-other); only auto_enable survives.
assert {d["endpoint"] for d in destinations} == {_ARIZE_ENDPOINT}
@pytest.mark.asyncio
async def test_resolve_access_global_alone_does_not_fire(
_seeded_logging_credentials_with_access,
):
"""The headline regression: a destination with access.global but no auto_enable
and no name must NOT fire for an unassigned caller. Mutating the resolver back to
selecting on access alone re-adds arize-global here and fails this test."""
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
# an org with no grants, no names: only the auto_enable default fires.
destinations, _ = await _resolve_logging_exporters(_auth(org_id="org-unrelated"))
# exactly one destination -- the auto_enable arize-default (space_id "D").
# arize-global shares the arize endpoint but carries space_id "S"; if access.global
# auto-fired it would survive as a SECOND destination here.
assert len(destinations) == 1
assert destinations[0]["endpoint"] == _ARIZE_ENDPOINT
assert destinations[0]["headers"]["space_id"] == "D"
@pytest.mark.asyncio
async def test_resolve_logging_exporters_access_default_deny(
_seeded_logging_credentials,
):
"""With no auto_enable and no identity assignment, nothing resolves even though
the seeded creds are access.global-visible -- visibility never invents a
destination."""
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 == []
@pytest.mark.asyncio
async def test_resolve_org_scoped_via_team_when_token_has_no_org_id(monkeypatch):
"""A team key whose token carries no org_id must still resolve an org-scoped
destination, via the team's organization_id. The write gate loads the team and
accepts the assignment, so the resolver must agree (M1); without the fallback the
org-granted destination is named but invisible (org_id None) and silently dropped.
Reverting _effective_org_id to user_api_key_dict.org_id fails this test."""
from types import SimpleNamespace
import litellm.proxy.proxy_server as proxy_server
from litellm.models.credentials import CredentialItem
from litellm.proxy.auth import auth_checks
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
original = litellm.credential_list
litellm.credential_list = [
CredentialItem(
credential_name="arize-org",
credential_values={"arize_space_id": "S", "arize_api_key": "K"},
credential_info={
"credential_type": "logging",
"description": "arize",
"access": {"orgs": ["org-7"]},
},
),
]
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
monkeypatch.setattr(
auth_checks, "get_key_object", AsyncMock(return_value=SimpleNamespace(metadata={}))
)
monkeypatch.setattr(
auth_checks,
"get_org_object",
AsyncMock(return_value=SimpleNamespace(metadata={})),
)
# the key's token has no org_id; the team it belongs to is in org-7.
monkeypatch.setattr(
auth_checks,
"get_team_object",
AsyncMock(return_value=SimpleNamespace(organization_id="org-7")),
)
try:
destinations, _ = await _resolve_logging_exporters(
_auth(team_id="team-x", team_exporters=["arize-org"])
)
assert {d["endpoint"] for d in destinations} == {"https://otlp.arize.com/v1"}
finally:
litellm.credential_list = original

View file

@ -1,5 +1,5 @@
{
"@typescript-eslint/no-explicit-any": 2026,
"complexity": 128,
"max-depth": 61
"@typescript-eslint/no-explicit-any": 2029,
"complexity": 129,
"max-depth": 62
}

View file

@ -76,6 +76,7 @@ interface EditTeamModalProps {
import { updateExistingKeys } from "@/utils/dataUtils";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import LoggingExportersSelect from "./logging_credentials/LoggingExportersSelect";
import { Member, teamCreateCall } from "./networking";
import { ModelSelect } from "./ModelSelect/ModelSelect";
@ -469,6 +470,22 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
formValues.metadata = JSON.stringify(metadata);
}
// Merge admin-owned logging_exporters into metadata at create time so the
// user can assign destinations from the new-team form (instead of create-then-edit).
if (Array.isArray(formValues.logging_exporters) && formValues.logging_exporters.length > 0) {
let metadata: Record<string, unknown> = {};
if (typeof formValues.metadata === "string" && formValues.metadata.trim().length > 0) {
try {
metadata = JSON.parse(formValues.metadata);
} catch (e) {
console.warn("Invalid JSON in metadata field, starting with empty object");
}
}
metadata = { ...metadata, logging_exporters: formValues.logging_exporters };
formValues.metadata = JSON.stringify(metadata);
}
delete formValues.logging_exporters;
if (formValues.secret_manager_settings) {
if (typeof formValues.secret_manager_settings === "string") {
if (formValues.secret_manager_settings.trim() === "") {
@ -1569,6 +1586,14 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
<b>Logging Settings</b>
</AccordionHeader>
<AccordionBody>
<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."
className="mt-4"
>
<LoggingExportersSelect />
</Form.Item>
<div className="mt-4">
<PremiumLoggingSettings
value={loggingSettings}

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,93 @@ 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("renders a global destination's scope as a Global tag with no mode badge", () => {
const { getByText, queryByText } = render(
<LoggingCallbacksTable
callbacks={[
{
name: "langfuse-eu",
variables: NO_VARS,
credentialName: "langfuse-eu",
access: { global: true },
resolvedScope: { global: true, teams: [], orgs: [] },
},
]}
availableCallbacks={{}}
/>,
);
expect(getByText("Global")).toBeInTheDocument();
// a destination has no success/failure mode badge
expect(queryByText("Success")).not.toBeInTheDocument();
});
it("renders a scoped destination's resolved teams and orgs as labeled tags", () => {
const { getByText } = render(
<LoggingCallbacksTable
callbacks={[
{
name: "arize-eu",
variables: NO_VARS,
credentialName: "arize-eu",
access: { teams: ["t1", "t2"], orgs: ["o1"] },
resolvedScope: { global: false, teams: ["t1", "t2"], orgs: ["o1"] },
},
]}
availableCallbacks={{}}
/>,
);
expect(getByText("team: t1")).toBeInTheDocument();
expect(getByText("team: t2")).toBeInTheDocument();
expect(getByText("org: o1")).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

@ -1,6 +1,6 @@
import { Button } from "@tremor/react";
import type { TableProps } from "antd";
import { Table } from "antd";
import { Table, Tag } from "antd";
import Title from "antd/es/typography/Title";
import React from "react";
import TableIconActionButton from "../../../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
@ -19,9 +19,44 @@ 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 SCOPE_BADGES_LIMIT = 4;
// Renders the union of identities that route to this destination, with each team/org
// labeled by its alias. Global supersedes everything. Pulls from record.resolvedScope
// (computed at the page level from BOTH directions: destination-side access AND
// identity-side metadata.logging_exporters).
const ScopeCell: React.FC<{ record: AlertingObject }> = ({ record }) => {
const scope = record.resolvedScope;
if (!scope || (!scope.global && scope.teams.length === 0 && scope.orgs.length === 0)) {
return <span className="text-gray-400"></span>;
}
if (scope.global) {
return <Tag color="blue">Global</Tag>;
}
const items = [
...scope.teams.map((label) => ({ kind: "team" as const, label })),
...scope.orgs.map((label) => ({ kind: "org" as const, label })),
];
const shown = items.slice(0, SCOPE_BADGES_LIMIT);
const remainder = items.length - shown.length;
return (
<div className="flex flex-wrap gap-1">
{shown.map((item, i) => (
<Tag key={`${item.kind}-${item.label}-${i}`} color={item.kind === "team" ? "blue" : "geekblue"}>
{item.kind}: {item.label}
</Tag>
))}
{remainder > 0 && <Tag>+{remainder} more</Tag>}
</div>
);
};
type CallbackRow = AlertingObject & {
id?: string;
mode?: "success" | "failure" | "info" | string;
@ -39,6 +74,7 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
onTest = () => {},
onEdit = () => {},
onDelete = () => {},
onEditAccess = () => {},
onAdd = () => {},
}) => {
const columns: TableProps<CallbackRow>["columns"] = [
@ -49,13 +85,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 +117,58 @@ 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) ? <ScopeCell record={record} /> : <span className="text-gray-400"></span>,
width: 280,
},
{
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,29 @@ 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;
// The union of identities that route to this destination, resolved at render
// time from both directions (destination-side credential_info.access AND
// identity-side metadata.logging_exporters). Display labels only -- ids are
// not surfaced here. global=true bypasses the lists.
resolvedScope?: ResolvedScope;
}
export interface CredentialAccess {
global?: boolean;
teams?: string[];
orgs?: string[];
}
export interface ResolvedScope {
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,73 @@
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="Visibility only: every team and org can see and assign this destination. It does not turn on tracing by itself -- name it on a key/team/org, or use Auto-enable, for that."
>
<Switch checked={isGlobal} onChange={(global) => onChange({ ...value, global })} />
</Form.Item>
<Form.Item
label="Teams"
tooltip="Admins of these teams can see and assign this destination; their keys export to it once it is named."
>
<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="Admins of these orgs can see and assign this destination; their keys export to it once it is named."
>
<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,158 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LoggingExportersSelect from "./LoggingExportersSelect";
const mockUseCredentials = vi.fn();
const mockUseAuthorized = vi.fn();
const mockUseTeams = vi.fn();
const mockUseOrganizations = vi.fn();
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
useCredentials: () => mockUseCredentials(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => mockUseTeams(),
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: () => mockUseOrganizations(),
}));
vi.mock("antd", async () => {
const React = await import("react");
function Select(props: any) {
const { value, onChange, options, notFoundContent } = props;
return React.createElement(
"div",
{ "data-testid": "logging-exporters-select" },
React.createElement(
"ul",
null,
(options ?? []).map((opt: any) =>
React.createElement("li", { key: opt.value, "data-testid": "option" }, opt.label),
),
),
options && options.length === 0 ? React.createElement("div", { "data-testid": "empty" }, notFoundContent) : null,
React.createElement(
"button",
{ "data-testid": "pick-first", onClick: () => onChange?.(options?.[0] ? [options[0].value] : []) },
"pick first",
),
React.createElement("div", { "data-testid": "value" }, JSON.stringify(value ?? [])),
);
}
return { Select };
});
beforeEach(() => {
// Default: a proxy admin (formatted role "Admin"), no team/org membership needed.
mockUseAuthorized.mockReturnValue({ userRole: "Admin" });
mockUseTeams.mockReturnValue({ data: [] });
mockUseOrganizations.mockReturnValue({ data: [] });
});
describe("LoggingExportersSelect", () => {
it("only surfaces credentials whose credential_type is 'logging'", () => {
mockUseCredentials.mockReturnValue({
data: {
credentials: [
{
credential_name: "poc-langfuse",
credential_info: { credential_type: "logging", host: "https://cloud.langfuse.com" },
},
{
credential_name: "poc-arize",
credential_info: { credential_type: "logging" },
},
{
credential_name: "openai-prod",
credential_info: { custom_llm_provider: "openai" },
},
],
},
});
render(<LoggingExportersSelect value={[]} onChange={() => {}} />);
const options = screen.getAllByTestId("option").map((el) => el.textContent);
expect(options).toEqual(["poc-langfuse (https://cloud.langfuse.com)", "poc-arize"]);
});
it("renders empty-state copy when no logging destinations exist", () => {
mockUseCredentials.mockReturnValue({
data: {
credentials: [
{
credential_name: "openai-prod",
credential_info: { custom_llm_provider: "openai" },
},
],
},
});
render(<LoggingExportersSelect value={[]} onChange={() => {}} />);
expect(screen.queryAllByTestId("option")).toHaveLength(0);
expect(screen.getByTestId("empty").textContent).toMatch(/proxy admin/i);
});
it("scopes a non-admin caller to destinations granted to their team/org (plus global/auto_enable)", () => {
// An internal_user who is a member of team-a. They must see only what they could
// actually assign: the team-a destination, the global one, and the auto_enable
// default -- never the team-b destination or the foreign-org one. This mirrors the
// backend assignment gate; the backend stays the authoritative check.
mockUseAuthorized.mockReturnValue({ userRole: "Internal User" });
mockUseTeams.mockReturnValue({ data: [{ team_id: "team-a" }] });
mockUseOrganizations.mockReturnValue({ data: [{ organization_id: "org-a" }] });
mockUseCredentials.mockReturnValue({
data: {
credentials: [
{
credential_name: "mine-team",
credential_info: { credential_type: "logging", access: { teams: ["team-a"] } },
},
{
credential_name: "foreign-team",
credential_info: { credential_type: "logging", access: { teams: ["team-b"] } },
},
{ credential_name: "mine-org", credential_info: { credential_type: "logging", access: { orgs: ["org-a"] } } },
{
credential_name: "foreign-org",
credential_info: { credential_type: "logging", access: { orgs: ["org-z"] } },
},
{ credential_name: "everyone", credential_info: { credential_type: "logging", access: { global: true } } },
{ credential_name: "always-on", credential_info: { credential_type: "logging", auto_enable: true } },
],
},
});
render(<LoggingExportersSelect value={[]} onChange={() => {}} />);
const options = screen.getAllByTestId("option").map((el) => el.textContent);
expect(options).toEqual(["mine-team", "mine-org", "everyone", "always-on"]);
});
it("shows every logging destination to a proxy admin regardless of access scope", () => {
mockUseAuthorized.mockReturnValue({ userRole: "Admin" });
mockUseTeams.mockReturnValue({ data: [] });
mockUseCredentials.mockReturnValue({
data: {
credentials: [
{
credential_name: "team-b-only",
credential_info: { credential_type: "logging", access: { teams: ["team-b"] } },
},
{ credential_name: "no-access", credential_info: { credential_type: "logging" } },
],
},
});
render(<LoggingExportersSelect value={[]} onChange={() => {}} />);
const options = screen.getAllByTestId("option").map((el) => el.textContent);
expect(options).toEqual(["team-b-only", "no-access"]);
});
});

View file

@ -0,0 +1,72 @@
import { Select } from "antd";
import React from "react";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isAdminRole } from "@/utils/roles";
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.
*
* Options are scoped to what the caller can actually assign: a proxy admin sees every
* destination; everyone else sees only the ones visible to a team or org they belong to
* (plus global / auto_enable destinations). This mirrors the backend assignment gate so
* a team admin is not offered another tenant's destination only to have the save
* rejected, and it avoids surfacing other tenants' destination names. The backend stays
* the authoritative check -- this filter is UX, not a security boundary.
*/
const LoggingExportersSelect: React.FC<LoggingExportersSelectProps> = ({ value, onChange }) => {
const { data } = useCredentials();
const { userRole } = useAuthorized();
const { data: teams } = useTeams();
const { data: orgs } = useOrganizations();
const seesEveryDestination = isAdminRole(userRole ?? "");
const myTeamIds = new Set((teams ?? []).map((t) => t.team_id));
const myOrgIds = new Set((orgs ?? []).map((o) => o.organization_id));
const assignable = (info: {
auto_enable?: boolean;
access?: { global?: boolean; teams?: string[]; orgs?: string[] };
}) => {
if (info.auto_enable === true || info.access?.global === true) return true;
if ((info.access?.teams ?? []).some((id) => myTeamIds.has(id))) return true;
return (info.access?.orgs ?? []).some((id) => myOrgIds.has(id));
};
const options = (data?.credentials ?? [])
.filter((credential) => credential.credential_info?.credential_type === "logging")
.filter((credential) => seesEveryDestination || assignable(credential.credential_info))
.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 available. Ask your proxy admin to create one under Settings -> Logging Callbacks."
/>
);
};
export default LoggingExportersSelect;

View file

@ -0,0 +1,45 @@
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;
autoEnable?: boolean;
}
// One place that owns the logging-credential contract: the credential_type tag, the
// backend in description, the non-secret host, the admin-owned access grant, and the
// explicit global/default (auto_enable) opt-in.
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 } : {}),
...(input.autoEnable ? { auto_enable: true } : {}),
},
});

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { LOGGING_DESTINATION_BACKENDS } from "./loggingDestinationFields";
// Arize routes a trace to a project via the model_id / arize.project.name span
// resource attribute, and its OTLP ingestion rejects any span that lacks it
// ("model_id span resource attribute or arize.project.name span attribute is
// required"). The backend reads that project from the credential's
// arize_project_name value, so the create form must collect it as a required
// field; without it every Arize destination created in the UI silently drops
// 100% of its traces.
describe("Arize logging destination fields", () => {
const arize = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === "arize");
it("exposes a required arize_project_name field", () => {
expect(arize).toBeDefined();
const projectField = arize!.fields.find((f) => f.name === "arize_project_name");
expect(projectField).toBeDefined();
expect(projectField!.optional).not.toBe(true);
});
});

View file

@ -0,0 +1,131 @@
// 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;
// Example value shown as the input placeholder, so an admin knows the format.
placeholder?: string;
}
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",
placeholder: "https://cloud.langfuse.com",
},
{
name: "langfuse_public_key",
label: "Public Key",
type: "password",
placeholder: "pk-lf-00000000-0000-0000-0000-000000000000",
},
{
name: "langfuse_secret_key",
label: "Secret Key",
type: "password",
placeholder: "sk-lf-00000000-0000-0000-0000-000000000000",
},
],
hostField: "langfuse_host",
},
{
id: "arize",
label: "Arize",
fields: [
{
name: "arize_space_id",
label: "Space ID",
type: "password",
placeholder: "U3BhY2U6MTIzNDU6YWJjZA==",
},
{
name: "arize_api_key",
label: "API Key",
type: "password",
placeholder: "ak-0000aaaa-1111-2222-3333-444455556666",
},
{
name: "arize_project_name",
label: "Project Name",
type: "text",
placeholder: "my-llm-app",
},
{
name: "arize_endpoint",
label: "Endpoint",
type: "text",
optional: true,
placeholder: "https://otlp.arize.com/v1",
},
],
hostField: "arize_endpoint",
},
{
id: "weave_otel",
label: "Weave",
fields: [
{
name: "wandb_api_key",
label: "W&B API Key",
type: "password",
placeholder: "0123456789abcdef0123456789abcdef01234567",
},
{
name: "weave_project_id",
label: "Project (entity/project)",
type: "text",
placeholder: "my-team/my-project",
},
{
name: "weave_endpoint",
label: "Endpoint",
type: "text",
optional: true,
placeholder: "https://trace.wandb.ai",
},
],
hostField: "weave_endpoint",
},
{
id: "generic",
label: "Generic OTLP Collector",
fields: [
{
name: "otel_endpoint",
label: "OTLP Endpoint",
type: "text",
placeholder: "https://collector.example.com:4318/v1/traces",
},
{
name: "otel_headers",
label: "Headers (k=v,k2=v2)",
type: "text",
optional: true,
placeholder: "x-api-key=abc123,x-team=42",
},
],
hostField: "otel_endpoint",
},
];

View file

@ -13,6 +13,12 @@ interface LoggingConfig {
interface LoggingSettingsViewProps {
loggingConfigs?: LoggingConfig[];
disabledCallbacks?: string[];
// Destinations this identity assigned itself, via metadata.logging_exporters.
loggingExporters?: string[];
// Destinations that target this identity via the credential's own scope
// (credential_info.access.{teams,orgs,global}) -- the other direction. The
// resolver unions both at request time; the UI unions them here for display.
scopedExporters?: string[];
variant?: "card" | "inline";
className?: string;
}
@ -20,6 +26,8 @@ interface LoggingSettingsViewProps {
export function LoggingSettingsView({
loggingConfigs = [],
disabledCallbacks = [],
loggingExporters = [],
scopedExporters = [],
variant = "card",
className = "",
}: LoggingSettingsViewProps) {
@ -57,6 +65,46 @@ export function LoggingSettingsView({
const content = (
<div className="space-y-6">
{/* Logging Exporters: the union of destinations routing to this identity.
Own = destinations this identity listed in its metadata.logging_exporters.
Via scope = destinations whose credential_info.access targets this identity
(a team/org id, or global). Both directions count; we render them together,
marking how each entry was resolved. */}
<div className="space-y-3">
{(() => {
const ownSet = new Set(loggingExporters);
const scopedOnly = scopedExporters.filter((name) => !ownSet.has(name));
const entries = [
...loggingExporters.map((name) => ({ name, source: "own" as const })),
...scopedOnly.map((name) => ({ name, source: "scope" as const })),
];
return (
<>
<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">{entries.length}</Tag>
</div>
{entries.length > 0 ? (
<div className="flex flex-wrap gap-2">
{entries.map((entry, index) => (
<Tag key={index} color={entry.source === "own" ? "blue" : "geekblue"}>
{entry.name}
{entry.source === "scope" ? " (via scope)" : ""}
</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

@ -210,6 +210,23 @@ 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: who may see/assign it.
// global reaches everyone; teams/orgs list ids. Visibility only -- on its own it
// never enables tracing for a request.
access?: {
global?: boolean;
teams?: string[];
orgs?: string[];
};
// Explicit global/default: when true the destination exports on every request
// without being named on any key/team/org. The deliberate replacement for the
// old behavior where access.global implicitly auto-enabled.
auto_enable?: boolean;
};
}

View file

@ -12,6 +12,8 @@ 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 { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { ModelSelect } from "../ModelSelect/ModelSelect";
import NotificationsManager from "../molecules/notifications_manager";
import {
@ -60,6 +62,25 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
// Destinations whose credential_info.access targets THIS org (or is global).
// Rendered alongside the org's own metadata.logging_exporters so the Logging
// Exporters card reflects BOTH routing directions, matching the resolver's
// union at request time.
const { data: orgCredentialsData } = useCredentials();
const scopedExportersForOrg = useMemo<string[]>(() => {
const orgId = orgData?.organization_id;
if (orgId == null) return [];
return (orgCredentialsData?.credentials ?? [])
.filter((c) => c.credential_info?.credential_type === "logging")
.filter((c) => {
const access = c.credential_info?.access;
if (!access) return false;
if (access.global === true) return true;
return Array.isArray(access.orgs) && access.orgs.includes(orgId);
})
.map((c) => c.credential_name);
}, [orgCredentialsData?.credentials, orgData?.organization_id]);
const handleMemberAdd = async (values: any) => {
try {
if (accessToken == null) {
@ -134,7 +155,10 @@ 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 +332,32 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
))}
</div>
</Card>
<Card>
<Text>Logging Exporters</Text>
<div className="mt-2 flex flex-wrap gap-2">
{(() => {
const own = Array.isArray(orgData.metadata?.logging_exporters)
? (orgData.metadata.logging_exporters as string[])
: [];
const ownSet = new Set(own);
const scopedOnly = scopedExportersForOrg.filter((n) => !ownSet.has(n));
const all = [
...own.map((n) => ({ n, source: "own" as const })),
...scopedOnly.map((n) => ({ n, source: "scope" as const })),
];
return all.length > 0 ? (
all.map((e, i) => (
<Badge key={i} color={e.source === "own" ? "blue" : "indigo"}>
{e.n}
{e.source === "scope" ? " (via scope)" : ""}
</Badge>
))
) : (
<Text className="text-gray-400">None</Text>
);
})()}
</div>
</Card>
<ObjectPermissionsView
objectPermission={orgData.object_permission}
@ -366,6 +416,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 +488,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 +550,32 @@ 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>
{(() => {
const own = Array.isArray(orgData.metadata?.logging_exporters)
? (orgData.metadata.logging_exporters as string[])
: [];
const ownSet = new Set(own);
const scopedOnly = scopedExportersForOrg.filter((n) => !ownSet.has(n));
const all = [
...own.map((n) => ({ n, source: "own" as const })),
...scopedOnly.map((n) => ({ n, source: "scope" as const })),
];
return all.length > 0 ? (
<div className="flex flex-wrap gap-2 mt-1">
{all.map((e, i) => (
<Badge key={i} color={e.source === "own" ? "blue" : "indigo"}>
{e.n}
{e.source === "scope" ? " (via scope)" : ""}
</Badge>
))}
</div>
) : (
<div className="text-gray-400 mt-1">None</div>
);
})()}
</div>
<ObjectPermissionsView
objectPermission={orgData.object_permission}

View file

@ -29,6 +29,7 @@ import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { formatNumberWithCommas } from "../utils/dataUtils";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import LoggingExportersSelect from "./logging_credentials/LoggingExportersSelect";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
@ -158,6 +159,17 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
}
}
if (Array.isArray(values.logging_exporters) && values.logging_exporters.length > 0) {
let existingMetadata: Record<string, unknown> = {};
if (typeof values.metadata === "string" && values.metadata.trim().length > 0) {
existingMetadata = JSON.parse(values.metadata);
} else if (values.metadata && typeof values.metadata === "object") {
existingMetadata = values.metadata;
}
values.metadata = { ...existingMetadata, logging_exporters: values.logging_exporters };
}
delete values.logging_exporters;
await organizationCreateCall(accessToken, values);
NotificationsManager.success("Organization created successfully");
setIsOrgModalVisible(false);
@ -513,6 +525,14 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
/>
</Form.Item>
<Form.Item
label="Logging Exporters"
name="logging_exporters"
tooltip="Admin-owned trace destinations this org exports to. Resolved server-side and fanned out to every team and key under it. Manage destinations under Settings -> Logging Callbacks."
>
<LoggingExportersSelect />
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} />
</Form.Item>

View file

@ -1,8 +1,19 @@
import { act, fireEvent, render, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking";
import Settings from "./settings";
// Settings (and its CloudZero cost-tracking child) renders react-query hooks, so
// every render must sit under a QueryClientProvider. Retries off so a failed
// query surfaces immediately instead of hanging the test.
const renderSettings = (props: Record<string, unknown>) =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<Settings {...(props as any)} />
</QueryClientProvider>,
);
vi.mock("./networking", () => ({
getCallbacksCall: vi.fn(),
getCallbackConfigsCall: vi.fn(),
@ -38,6 +49,13 @@ vi.mock("./CloudZeroCostTracking/CloudZeroCostTracking", () => ({
default: () => <div>Mock CloudZero Cost Tracking</div>,
}));
// Settings now pulls logging-destination credentials via the useCredentials
// react-query hook; the test renders <Settings> without a QueryClientProvider,
// so stub the hook to a stable empty result instead of standing up a client.
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
useCredentials: () => ({ data: { credentials: [] }, refetch: vi.fn() }),
}));
// Polyfill ResizeObserver for components relying on it in tests
if (typeof window !== "undefined" && !window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
@ -86,7 +104,7 @@ describe("Settings", () => {
});
it("should render the logging callbacks tab when access token is provided", async () => {
const { getByText } = render(<Settings {...defaultProps} />);
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
@ -94,7 +112,7 @@ describe("Settings", () => {
});
it("should display additional settings tabs", async () => {
const { getByText } = render(<Settings {...defaultProps} />);
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("CloudZero Cost Tracking")).toBeInTheDocument();
@ -105,7 +123,7 @@ describe("Settings", () => {
});
it("should load callback configs from the backend when access token is provided", async () => {
render(<Settings {...defaultProps} />);
renderSettings(defaultProps);
await waitFor(() => {
expect(mockGetCallbackConfigsCall).toHaveBeenCalledWith(defaultProps.accessToken);
@ -113,35 +131,31 @@ describe("Settings", () => {
});
it("should display edit modal with fields when edit is clicked", async () => {
// Use a config callback that is NOT a logging-destination backend (langfuse,
// arize, etc. are filtered from the table via NON_CALLBACK_LOGGING_IDS and
// edited through the destination flow instead). Datadog is a plain config
// callback, so it still renders a row with the legacy Test/Edit/Delete actions.
const mockCallback = {
name: "langfuse",
name: "datadog",
variables: {
LANGFUSE_PUBLIC_KEY: "test-public-key",
LANGFUSE_SECRET_KEY: "test-secret-key",
LANGFUSE_HOST: "https://test.langfuse.com",
SLACK_WEBHOOK_URL: null,
OPENMETER_API_KEY: null,
DD_API_KEY: "test-api-key",
DD_SITE: "us5.datadoghq.com",
},
};
const mockCallbackConfig = {
id: "langfuse",
displayName: "Langfuse",
id: "datadog",
displayName: "Datadog",
dynamic_params: {
LANGFUSE_PUBLIC_KEY: {
type: "text",
ui_name: "Public Key",
required: true,
},
LANGFUSE_SECRET_KEY: {
DD_API_KEY: {
type: "password",
ui_name: "Secret Key",
ui_name: "API Key",
required: true,
},
LANGFUSE_HOST: {
DD_SITE: {
type: "text",
ui_name: "Host",
required: false,
ui_name: "Site",
required: true,
},
},
};
@ -149,10 +163,10 @@ describe("Settings", () => {
mockGetCallbacksCall.mockResolvedValue({
callbacks: [mockCallback],
available_callbacks: {
langfuse: {
litellm_callback_name: "langfuse",
litellm_callback_params: ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST"],
ui_callback_name: "Langfuse",
datadog: {
litellm_callback_name: "datadog",
litellm_callback_params: ["DD_API_KEY", "DD_SITE"],
ui_callback_name: "Datadog",
},
},
alerts: [],
@ -160,14 +174,14 @@ describe("Settings", () => {
mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]);
const { getByText, container } = render(<Settings {...defaultProps} />);
const { getByText, container } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
});
await waitFor(() => {
expect(getByText("Langfuse")).toBeInTheDocument();
expect(getByText("Datadog")).toBeInTheDocument();
});
const actionsCell = container.querySelector('[class*="flex justify-end gap-2"]');
@ -188,14 +202,13 @@ describe("Settings", () => {
});
await waitFor(() => {
expect(getByText("Public Key")).toBeInTheDocument();
expect(getByText("Secret Key")).toBeInTheDocument();
expect(getByText("Host")).toBeInTheDocument();
expect(getByText("API Key")).toBeInTheDocument();
expect(getByText("Site")).toBeInTheDocument();
});
});
it("should display CloudZero Cost Tracking tab", async () => {
const { getByText } = render(<Settings {...defaultProps} />);
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();

View file

@ -32,6 +32,7 @@ import AlertingSettings from "./alerting/alerting_settings";
import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import {
credentialDeleteCall,
deleteCallback,
getCallbackConfigsCall,
getCallbacksCall,
@ -39,7 +40,19 @@ import {
setCallbacksCall,
} from "./networking";
import { LoggingCallbacksTable } from "./Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable";
import { AlertingObject } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { AlertingObject, CredentialAccess, ResolvedScope } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
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;
@ -249,6 +262,78 @@ 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 { data: teamsData } = useTeams();
const { data: orgsData } = useOrganizations();
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>({});
// explicit global/default (auto_enable) opt-in for the destination branch
const [addAutoEnable, setAddAutoEnable] = useState(false);
const addingDestination = selectedCallback != null && LOGGING_BACKEND_IDS.has(selectedCallback);
const addingDestinationFields = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === selectedCallback)?.fields ?? [];
const teamAlias = (id: string): string => {
const t = (teamsData ?? []).find((team) => team.team_id === id);
return t?.team_alias || id;
};
const orgAlias = (id: string): string => {
const o = (orgsData ?? []).find((org) => org.organization_id === id);
return o?.organization_alias || id;
};
// For each destination, the Scope column reflects BOTH directions:
// (a) destination-side credential_info.access (global/teams/orgs on the credential)
// (b) identity-side metadata.logging_exporters on each team/org that lists this destination
// The resolver unions them at request time; the column unions them at render time.
const resolveScope = (destinationName: string, access?: CredentialAccess): ResolvedScope => {
const teams = new Set<string>();
const orgs = new Set<string>();
let global = access?.global === true;
for (const teamId of access?.teams ?? []) teams.add(teamAlias(teamId));
for (const orgId of access?.orgs ?? []) orgs.add(orgAlias(orgId));
for (const team of teamsData ?? []) {
const teamMetadata = (team as { metadata?: Record<string, unknown> | null }).metadata;
const exporters = teamMetadata?.logging_exporters;
if (Array.isArray(exporters) && exporters.includes(destinationName)) {
teams.add(team.team_alias || team.team_id);
}
}
for (const org of orgsData ?? []) {
const exporters = (org.metadata as Record<string, unknown> | null | undefined)?.logging_exporters;
if (Array.isArray(exporters) && exporters.includes(destinationName)) {
orgs.add(org.organization_alias || org.organization_id);
}
}
return { global, teams: Array.from(teams), orgs: Array.from(orgs) };
};
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,
resolvedScope: resolveScope(c.credential_name, 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;
@ -381,6 +466,35 @@ 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,
autoEnable: addAutoEnable,
});
NotificationsManager.success("Logging destination created");
refetchCredentials();
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setAddAccess({});
setAddAutoEnable(false);
addForm.resetFields();
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
return;
}
await handleCallbackSubmit(formValues, new_callback, false);
};
@ -579,14 +693,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);
@ -596,6 +715,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">
@ -704,6 +833,8 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
setAddAccess({});
setAddAutoEnable(false);
}}
footer={null}
>
@ -725,16 +856,54 @@ 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" placeholder={f.placeholder} />
) : (
<Input size="large" placeholder={f.placeholder} />
)}
</FormItem>
))}
<AccessControlFields value={addAccess} onChange={setAddAccess} />
<Form.Item
label="Auto-enable for all requests"
tooltip="When on, every request exports its traces to this destination automatically, without being named on a key, team, or org. The explicit global default; replaces relying on Global to auto-enable."
>
<Switch checked={addAutoEnable} onChange={setAddAutoEnable} />
</Form.Item>
</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
@ -742,6 +911,8 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
setAddAccess({});
setAddAutoEnable(false);
addForm.resetFields();
}}
disabled={isAddingCallback}
@ -749,7 +920,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

@ -31,6 +31,7 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeam: vi.fn(),
useTeams: vi.fn().mockReturnValue({ data: [] }),
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({

View file

@ -41,6 +41,7 @@ import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSel
import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key";
import GuardrailSettingsView from "../GuardrailSettingsView";
import LoggingSettingsView from "../logging_settings_view";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import { ModelSelect } from "../ModelSelect/ModelSelect";
@ -51,6 +52,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";
@ -233,6 +235,25 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}, [selectedModelsInForm, teamData, userModels]);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam;
// Destinations whose credential_info.access targets this team (or its org, or
// global). Rendered alongside the team's own metadata.logging_exporters so the
// Logging Exporters card reflects BOTH routing directions, matching the
// resolver's union at request time.
const { data: scopedCredentialsData } = useCredentials();
const scopedExportersForTeam = useMemo<string[]>(() => {
const orgId = teamData?.team_info?.organization_id ?? null;
return (scopedCredentialsData?.credentials ?? [])
.filter((c) => c.credential_info?.credential_type === "logging")
.filter((c) => {
const access = c.credential_info?.access;
if (!access) return false;
if (access.global === true) return true;
if (Array.isArray(access.teams) && access.teams.includes(teamId)) return true;
return Array.isArray(access.orgs) && orgId != null && access.orgs.includes(orgId);
})
.map((c) => c.credential_name);
}, [scopedCredentialsData?.credentials, teamId, teamData?.team_info?.organization_id]);
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
@ -530,6 +551,7 @@ 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"
@ -541,7 +563,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}),
},
...(values.policies?.length > 0 ? { policies: values.policies } : {}),
...(values.organization_id !== info.organization_id ? { organization_id: values.organization_id ?? null } : {}),
// organization_id always sent so the backend route gate can identify
// org-admin callers (the gate matches on body.organization_id; omitting
// it falls through to default-deny for a non-PROXY_ADMIN caller even
// if they admin the team's current org).
organization_id: values.organization_id !== undefined ? values.organization_id ?? null : info.organization_id,
};
updateData.max_budget = mapEmptyStringToNull(updateData.max_budget);
@ -862,6 +888,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<LoggingSettingsView
loggingConfigs={info.metadata?.logging || []}
loggingExporters={
Array.isArray(info.metadata?.logging_exporters) ? info.metadata.logging_exporters : []
}
scopedExporters={scopedExportersForTeam}
disabledCallbacks={[]}
variant="card"
/>
@ -974,6 +1004,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 +1456,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Form.Item>
<Form.Item
label="Logging Exporters"
name="logging_exporters"
tooltip="Trace destinations this team exports to. Resolved server-side and unioned with the key's and org's destinations. Destinations are created by the proxy admin; team admins may attach any of them to teams they admin."
>
<LoggingExportersSelect />
</Form.Item>
<Form.Item label="Logging Settings" name="logging_settings">
<EditLoggingSettings
value={form.getFieldValue("logging_settings")}
@ -1639,6 +1678,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<LoggingSettingsView
loggingConfigs={info.metadata?.logging || []}
loggingExporters={
Array.isArray(info.metadata?.logging_exporters) ? info.metadata.logging_exporters : []
}
scopedExporters={scopedExportersForTeam}
disabledCallbacks={[]}
variant="inline"
className="pt-4 border-t border-gray-200"

View file

@ -267,6 +267,16 @@ vi.mock("@/app/(dashboard)/hooks/keys/useResetKeySpend", () => ({
}),
}));
// KeyInfoView's Logging Exporters select pulls credentials + orgs via react-query;
// mock both so this QueryClientProvider-free unit test of handleKeyUpdate runs.
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
useCredentials: vi.fn().mockReturnValue({ data: { credentials: [] }, refetch: vi.fn() }),
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: vi.fn().mockReturnValue({ data: [], refetch: vi.fn() }),
}));
// KeyEditView mock: triggers onSubmit with our injected form values
vi.mock("./key_edit_view", async () => {
const React = await import("react");

View file

@ -27,6 +27,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 {
@ -190,6 +191,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)
: [],
@ -219,6 +221,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)
: [],
@ -731,6 +734,14 @@ export function KeyEditView({
<Input value={projectDisplay ?? ""} disabled />
</Form.Item>
)}
<Form.Item
label="Logging Exporters"
name="logging_exporters"
tooltip="Trace destinations this key exports to. Resolved server-side and unioned with the team's and org's destinations. Destinations are created by the proxy admin; team admins may attach any of them to keys in their team."
>
<LoggingExportersSelect />
</Form.Item>
<Form.Item label="Logging Settings" name="logging_settings">
<EditLoggingSettings
value={form.getFieldValue("logging_settings")}

View file

@ -8,7 +8,7 @@ import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import { Form, Modal, Tag } from "antd";
import { KeyInfoHeader } from "./KeyInfoHeader";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles";
import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "../callback_info_helpers";
import AutoRotationView from "../common_components/AutoRotationView";
@ -16,6 +16,8 @@ import DeleteResourceModal from "../common_components/DeleteResourceModal";
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
import { KeyResponse } from "../key_team_helpers/key_list";
import LoggingSettingsView from "../logging_settings_view";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import NotificationManager from "../molecules/notifications_manager";
import { getPolicyInfoWithGuardrails, keyDeleteCall, keyUpdateCall } from "../networking";
import { useResetKeySpend } from "@/app/(dashboard)/hooks/keys/useResetKeySpend";
@ -66,6 +68,8 @@ export default function KeyInfoView({
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
const { teams: teamsData } = useTeams();
const { data: keyCredentialsData } = useCredentials();
const { data: keyOrganizationsData } = useOrganizations();
const { data: projects } = useProjects();
const { data: uiSettingsData } = useUISettings();
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
@ -79,6 +83,26 @@ export default function KeyInfoView({
const { mutate: resetKeySpend, isPending: resetSpendLoading } = useResetKeySpend();
// Add local state to maintain key data and track regeneration
const [currentKeyData, setCurrentKeyData] = useState<KeyResponse | undefined>(keyData);
// Destinations whose credential_info.access targets THIS key (via its team_id,
// its team's organization_id, or global). Rendered alongside the key's own
// metadata.logging_exporters so the Logging Exporters section reflects BOTH
// routing directions, matching the resolver's union at request time.
const scopedExportersForKey = useMemo<string[]>(() => {
const keyTeamId = (currentKeyData as { team_id?: string | null } | undefined)?.team_id ?? null;
const team = (teamsData ?? []).find((t) => t.team_id === keyTeamId);
const teamOrgId = (team as { organization_id?: string | null } | undefined)?.organization_id ?? null;
return (keyCredentialsData?.credentials ?? [])
.filter((c) => c.credential_info?.credential_type === "logging")
.filter((c) => {
const access = c.credential_info?.access;
if (!access) return false;
if (access.global === true) return true;
if (Array.isArray(access.teams) && keyTeamId && access.teams.includes(keyTeamId)) return true;
return Array.isArray(access.orgs) && teamOrgId != null && access.orgs.includes(teamOrgId);
})
.map((c) => c.credential_name);
}, [keyCredentialsData?.credentials, currentKeyData, teamsData, keyOrganizationsData]);
const [lastRegeneratedAt, setLastRegeneratedAt] = useState<Date | null>(null);
const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false);
const [policyGuardrails, setPolicyGuardrails] = useState<Record<string, string[]>>({});
@ -254,6 +278,7 @@ 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 +300,7 @@ 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),
@ -617,6 +643,12 @@ export default function KeyInfoView({
<LoggingSettingsView
loggingConfigs={extractLoggingSettings(currentKeyData.metadata)}
loggingExporters={
Array.isArray(currentKeyData.metadata?.logging_exporters)
? currentKeyData.metadata.logging_exporters
: []
}
scopedExporters={scopedExportersForKey}
disabledCallbacks={
Array.isArray(currentKeyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(currentKeyData.metadata.litellm_disabled_callbacks)
@ -865,6 +897,12 @@ export default function KeyInfoView({
<LoggingSettingsView
loggingConfigs={extractLoggingSettings(currentKeyData.metadata)}
loggingExporters={
Array.isArray(currentKeyData.metadata?.logging_exporters)
? currentKeyData.metadata.logging_exporters
: []
}
scopedExporters={scopedExportersForKey}
disabledCallbacks={
Array.isArray(currentKeyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(currentKeyData.metadata.litellm_disabled_callbacks)

View file

@ -2423,6 +2423,13 @@ export interface paths {
/**
* Get Credentials
* @description [BETA] endpoint. This might change unexpectedly.
*
* Proxy admins see every credential (values masked). Team-admins and
* org-admins see only logging-typed destinations so they can self-assign
* them; provider credentials stay invisible to non-PROXY_ADMINs. Plain
* internal users with no team-admin or org-admin status get 403 they
* have no use for the list and shouldn't see destination names, hosts,
* or scope metadata (Veria F2).
*/
get: operations["get_credentials_credentials_get"];
put?: never;
@ -2499,6 +2506,11 @@ export interface paths {
/**
* Update Credential
* @description [BETA] endpoint. This might change unexpectedly.
*
* Both ``credential_values`` and ``credential_info`` are optional; a team-admin
* typically patches only ``credential_info.access`` to grant or revoke their
* own team. A proxy admin may patch either or both. See
* ``decide_credential_patch`` for the exact contract.
*/
patch: operations["update_credential_credentials__credential_name__patch"];
trace?: never;
@ -31431,6 +31443,27 @@ export interface components {
*/
workers: components["schemas"]["WorkerRegistryEntry"][];
};
/**
* UpdateCredentialItem
* @description PATCH body for ``/credentials/{name}``.
*
* Both ``credential_values`` and ``credential_info`` are optional so a caller
* can patch one without sending the other (team-admins patching access without
* knowing the upstream secrets; proxy admins rotating values without touching
* access). ``credential_name`` is optional because most patches don't rename.
*/
UpdateCredentialItem: {
/** Credential Info */
credential_info?: {
[key: string]: unknown;
} | null;
/** Credential Name */
credential_name?: string | null;
/** Credential Values */
credential_values?: {
[key: string]: unknown;
} | null;
};
/**
* UpdateCustomerRequest
* @description Update a Customer, use this to update customer budgets etc
@ -37166,7 +37199,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["CredentialItem"];
"application/json": components["schemas"]["UpdateCredentialItem"];
};
};
responses: {