mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
feat(otel/v2): admin-owned, identity-scoped trace destinations (LIT-3850)
This commit is contained in:
parent
8e30cfbeb1
commit
e8020da01e
79 changed files with 7373 additions and 508 deletions
|
|
@ -136,6 +136,7 @@ _custom_logger_compatible_callbacks_literal = Literal[
|
|||
"langfuse",
|
||||
"langfuse_otel",
|
||||
"weave_otel",
|
||||
"generic",
|
||||
"pagerduty",
|
||||
"humanloop",
|
||||
"azure_sentinel",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
# ====================================================================== #
|
||||
|
|
|
|||
27
litellm/integrations/otel/model/destination.py
Normal file
27
litellm/integrations/otel/model/destination.py
Normal 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())
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
204
litellm/integrations/otel/plumbing/fan_out.py
Normal file
204
litellm/integrations/otel/plumbing/fan_out.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
|
|
|||
130
litellm/integrations/otel/presets/destinations.py
Normal file
130
litellm/integrations/otel/presets/destinations.py
Normal 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)
|
||||
21
litellm/integrations/otel/presets/generic.py
Normal file
21
litellm/integrations/otel/presets/generic.py
Normal 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()
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
132
litellm/proxy/credential_endpoints/access_decision.py
Normal file
132
litellm/proxy/credential_endpoints/access_decision.py
Normal 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()
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
348
tests/test_litellm/integrations/otel/test_otel_v2_fan_out.py
Normal file
348
tests/test_litellm/integrations/otel/test_otel_v2_fan_out.py
Normal 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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
764
tests/test_litellm/proxy/credential_endpoints/test_endpoints.py
Normal file
764
tests/test_litellm/proxy/credential_endpoints/test_endpoints.py
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
35
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
35
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue