diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 2c83406afed..70a6a051ec5 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -21,6 +21,7 @@ from litellm.integrations.otel.model.config import OpenTelemetryV2Config from litellm.integrations.otel.model.metadata import ( LLMCallEvent, RequestIdentity, + auth_metadata, model_from_request_data, ) from litellm.integrations.otel.model.payloads import ( @@ -265,7 +266,7 @@ class OpenTelemetryV2(CustomLogger): 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), + tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params, call.auth_metadata), ) self._open_llm_calls[call_id] = _LLMCallSpan(span=span, start_time_ns=start_time_ns) # Evict the oldest open call if the map is over budget. A call that opens @@ -376,6 +377,7 @@ class OpenTelemetryV2(CustomLogger): start_time_ns=to_ns(start_time), end_time_ns=to_ns(end_time), links=links, + tracer=self._tenant_tracers.tracer_for(self.tracer, None, auth_metadata(payload, kwargs)), ) return True @@ -412,6 +414,7 @@ class OpenTelemetryV2(CustomLogger): start_time_ns=to_ns(start_time), end_time_ns=to_ns(end_time), links=links, + tracer=self._tenant_tracers.tracer_for(self.tracer, None, auth_metadata(payload, kwargs)), ) return True @@ -465,7 +468,7 @@ class OpenTelemetryV2(CustomLogger): parent_context=parent_ctx, start_time_ns=carrier.start_time_ns, end_time_ns=end_time_ns, - tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params), + tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params, call.auth_metadata), ) # ====================================================================== # diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index c7cdaae0417..de6366e7dbd 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -36,8 +36,9 @@ model. They coincide on the SDK path, which is correct. from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from dataclasses import dataclass, field +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, cast from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL @@ -195,6 +196,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 key/team config the proxy resolved at auth (``user_api_key_auth_metadata``), + # routing the call to that tenant's telemetry project. Server-set and so + # trusted, unlike ``dynamic_params``, which carries client-supplied metadata. + auth_metadata: Mapping[str, str] | None # 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 @@ -214,6 +219,7 @@ class LLMCallEvent: call_id=_call_id(payload, kwargs), payload=payload, dynamic_params=kwargs.get("standard_callback_dynamic_params"), + auth_metadata=auth_metadata(payload, kwargs), is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)), provisional_span_name=f"{operation.value} {model}".strip(), time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs), @@ -235,6 +241,64 @@ def time_to_first_chunk_seconds(kwargs: Mapping[str, Any]) -> float | None: return completion_start - api_call_start +def auth_metadata(payload: StandardLoggingPayload | None, kwargs: Mapping[str, object]) -> Mapping[str, str] | None: + """The key/team config the proxy resolved at auth, or ``None`` off the proxy. + + Read from the payload once the call closes and from ``litellm_params`` at + ``pre_call``, where no payload exists yet — the LLM-call span is *created* at + ``pre_call``, so the tracer (and therefore the destination) must be + resolvable there. Values arrive untyped, so non-string entries are dropped + rather than passed on to header builders. + """ + return next( + ( + typed + for metadata in _metadata_dicts(payload, kwargs) + if (typed := _string_entries(metadata.get("user_api_key_auth_metadata"))) + ), + None, + ) + + +def _as_str_mapping(value: object) -> Mapping[str, object] | None: + """A read-only view of ``value`` when it is a mapping, else ``None``.""" + if not isinstance(value, Mapping): + return None + return cast("Mapping[str, object]", value) # cast-ok: isinstance-guarded, JSON metadata has str keys + + +def _string_entries(value: object) -> Mapping[str, str] | None: + entries: Final = _as_str_mapping(value) + if entries is None: + return None + typed: Final = MappingProxyType({key: item for key, item in entries.items() if isinstance(item, str)}) + return typed or None + + +def _metadata_dicts( + payload: StandardLoggingPayload | None, kwargs: Mapping[str, object] +) -> Iterator[Mapping[str, object]]: + """Request metadata dicts, closed-call payload first then the live kwargs. + + ``litellm_metadata`` is the metadata field on the Anthropic-shaped routes; + litellm copies it onto ``metadata``, but both are yielded so a route that + populates only one is still covered. + """ + payload_view: Final = _as_str_mapping(payload) + if payload_view is not None: + payload_metadata: Final = _as_str_mapping(payload_view.get("metadata")) + if payload_metadata is not None: + yield payload_metadata + params: Final = _as_str_mapping(kwargs.get("litellm_params")) + if params is None: + return + yield from ( + metadata + for key in ("metadata", "litellm_metadata") + if (metadata := _as_str_mapping(params.get(key))) is not None + ) + + def _call_id(payload: StandardLoggingPayload | None, kwargs: Mapping[str, Any]) -> str | None: """The call id from the payload (when closed) or the bare kwargs (at pre_call).""" if payload is not None: diff --git a/litellm/integrations/otel/plumbing/routing.py b/litellm/integrations/otel/plumbing/routing.py index d9e9e84364a..f236343fae4 100644 --- a/litellm/integrations/otel/plumbing/routing.py +++ b/litellm/integrations/otel/plumbing/routing.py @@ -1,31 +1,43 @@ """Per-request multi-tenant tracer routing. 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. +``standard_callback_dynamic_params``, or the key/team config resolved at auth +names a destination project, its spans must export through a +``TracerProvider`` whose OTLP headers carry those credentials / that project. +``TenantTracerCache`` builds and caches one provider per distinct +(credentials, project) pair, 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. """ from collections import OrderedDict from collections.abc import Mapping -from typing import Any, Final +from types import MappingProxyType +from typing import Any, Final, TypeAlias +from urllib.parse import quote 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.model.config import ExporterSpec, OpenTelemetryV2Config from litellm.integrations.otel.plumbing.providers import ( build_tracer_provider, get_tracer, ) -from litellm.integrations.otel.presets import dynamic_otlp_headers +from litellm.integrations.otel.presets import ( + dynamic_otlp_headers, + project_routing_headers, +) # Exporter kinds that ignore headers — never rewritten with dynamic credentials. _NON_OTLP_KINDS: Final = ("console", "in_memory", "inmemory", "memory") +# gRPC exporters still take dynamic credentials (as gRPC metadata) but not +# project headers: the routing headers backends read (Phoenix's +# ``x-project-name``) are only honored on the OTLP/HTTP endpoint. +_GRPC_KINDS: Final = ("otlp_grpc", "grpc") + # 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 @@ -34,6 +46,10 @@ _NON_OTLP_KINDS: Final = ("console", "in_memory", "inmemory", "memory") # evicted providers so their threads are reclaimed. _MAX_CACHED_PROVIDERS: Final = 256 +_HeaderItems: TypeAlias = tuple[tuple[str, str], ...] + +_NO_HEADERS: Final[Mapping[str, str]] = MappingProxyType({}) + def _shutdown_provider(provider: TracerProvider) -> None: """Flush + stop an evicted provider's processors (reclaims their threads). @@ -49,8 +65,19 @@ def _shutdown_provider(provider: TracerProvider) -> None: verbose_logger.debug("OTel V2: error shutting down evicted provider: %s", e) +def _plain_header_string(headers: Mapping[str, str]) -> str: + return ",".join(f"{key}={value}" for key, value in headers.items()) + + +def _encoded_header_string(headers: Mapping[str, str]) -> str: + """Percent-encode values so one containing the ``k=v,k=v`` separators (e.g. + a project name with a comma) survives; ``parse_env_headers`` decodes it back. + """ + return ",".join(f"{key}={quote(value, safe='')}" for key, value in headers.items()) + + class TenantTracerCache: - """Credential-scoped ``TracerProvider`` cache keyed by the dynamic headers.""" + """Credential/project-scoped ``TracerProvider`` cache keyed by the routing headers.""" def __init__( self, @@ -61,49 +88,100 @@ 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[_HeaderItems, _HeaderItems], TracerProvider] = OrderedDict() + self._project_routable = any( + spec.owner == callback_name and spec.kind.lower() not in (*_NON_OTLP_KINDS, *_GRPC_KINDS) + for spec in config.exporters + ) + self._warned_project_unroutable = False - def tracer_for(self, default: Tracer, dynamic_params: Any) -> Tracer: + def tracer_for( + self, + default: Tracer, + dynamic_params: Any, + auth_metadata: Mapping[str, str] | None = None, + ) -> Tracer: """Return the tracer for this request. - Use ``default`` unless the request's dynamic credentials require a - credential-scoped tracer, in which case build (or reuse) one. The cache - is a bounded LRU: the least-recently-used provider is flushed and shut - down on overflow so its exporter threads don't accumulate. + Use ``default`` unless the request's dynamic credentials or its key/team + project require a 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. """ - headers: Final = dynamic_otlp_headers(self._callback_name, dynamic_params) - if not headers: + credential_headers: Final = dynamic_otlp_headers(self._callback_name, dynamic_params) or _NO_HEADERS + project_headers: Final = self._project_headers(auth_metadata) + if not credential_headers and not project_headers: return default - cache_key: Final = tuple(sorted(headers.items())) + cache_key: Final = ( + tuple(sorted(credential_headers.items())), + tuple(sorted(project_headers.items())), + ) 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._routed_config(credential_headers, project_headers)) 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 _project_headers(self, auth_metadata: Mapping[str, str] | None) -> Mapping[str, str]: + """The per-request project-routing headers, if this cache can apply them. - ``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. + A gRPC-only exporter can't (the project header route is HTTP-only), so + the request warns once and stays on the env-configured default project. """ - header_str: Final = ",".join(f"{key}={value}" for key, value in headers.items()) - header_update: Final[dict[str, str]] = {"headers": header_str} - exporters: Final = [ - ( - spec.model_copy(update=header_update) - if spec.owner == self._callback_name and spec.kind.lower() not in _NON_OTLP_KINDS - else spec + requested: Final = project_routing_headers(self._callback_name, auth_metadata) + if not requested or self._project_routable: + return requested + if not self._warned_project_unroutable: + self._warned_project_unroutable = True + verbose_logger.warning( + "OTel V2: %s key/team config names a per-request project, but its exporter " + "is not OTLP/HTTP and the project header is HTTP-only; spans stay in the " + "default project.", + self._callback_name, ) - for spec in self._config.exporters + return _NO_HEADERS + + def _routed_config( + self, + credential_headers: Mapping[str, str], + project_headers: Mapping[str, str], + ) -> OpenTelemetryV2Config: + """Clone the config, rewriting headers on the callback's own exporter. + + Both header sets apply only to the exporter ``self._callback_name`` + 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. + + Dynamic credentials REPLACE the exporter's headers — they are the + tenant's complete credential set. Project headers APPEND instead: the + preset's static headers carry the backend auth (Phoenix's + ``Authorization``), which must survive routing to a project. + """ + exporters: Final = [ + self._routed_exporter(spec, credential_headers, project_headers) for spec in self._config.exporters ] return self._config.model_copy(update={"exporters": exporters}) + + def _routed_exporter( + self, + spec: ExporterSpec, + credential_headers: Mapping[str, str], + project_headers: Mapping[str, str], + ) -> ExporterSpec: + kind: Final = spec.kind.lower() + if spec.owner != self._callback_name or kind in _NON_OTLP_KINDS: + return spec + base: Final = _plain_header_string(credential_headers) if credential_headers else spec.headers + routed: Final = ( + ",".join(part for part in (base, _encoded_header_string(project_headers)) if part) + if project_headers and kind not in _GRPC_KINDS + else base + ) + return spec if routed == spec.headers else spec.model_copy(update={"headers": routed}) diff --git a/litellm/integrations/otel/presets/__init__.py b/litellm/integrations/otel/presets/__init__.py index 95ac2783325..35b0584c697 100644 --- a/litellm/integrations/otel/presets/__init__.py +++ b/litellm/integrations/otel/presets/__init__.py @@ -8,7 +8,8 @@ the factory in ``litellm_logging`` can resolve a name and build a single ``OpenTelemetryV2`` instance from the result. """ -from collections.abc import Callable +from collections.abc import Callable, Mapping +from types import MappingProxyType from typing import Final from litellm.integrations.otel.presets.agentops import agentops_preset @@ -20,7 +21,10 @@ from litellm.integrations.otel.presets.langfuse import ( ) 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.phoenix import ( + phoenix_preset, + phoenix_project_headers, +) from litellm.integrations.otel.presets.weave import weave_dynamic_headers, weave_preset from litellm.types.utils import StandardCallbackDynamicParams @@ -47,6 +51,23 @@ DYNAMIC_HEADERS_BY_CALLBACK: Final[dict[str, Callable[[StandardCallbackDynamicPa } +#: Callback name → per-request *routing* header builder, sourced from the key/team +#: config the proxy resolved at auth. Deliberately separate from +#: ``DYNAMIC_HEADERS_BY_CALLBACK``: that one is fed +#: ``StandardCallbackDynamicParams``, which is populated from client-supplied +#: request metadata. Naming a destination project is a data-exfiltration +#: primitive, so it must only ever come from server-set key/team config. +PROJECT_HEADERS_BY_CALLBACK: Final[Mapping[str, Callable[[Mapping[str, str] | None], Mapping[str, str]]]] = ( + MappingProxyType( + { + "arize_phoenix": phoenix_project_headers, + } + ) +) + +_NO_PROJECT_HEADERS: Final[Mapping[str, str]] = MappingProxyType({}) + + def dynamic_otlp_headers( callback_name: str | None, dynamic_params: StandardCallbackDynamicParams | None, @@ -62,9 +83,25 @@ def dynamic_otlp_headers( return headers or None +def project_routing_headers( + callback_name: str | None, + auth_metadata: Mapping[str, str] | None, +) -> Mapping[str, str]: + """Per-request project-routing headers from trusted key/team config. + + Empty means "no per-request project" — the caller keeps its default tracer, + whose resource attributes carry the env-configured project. + """ + builder: Final = PROJECT_HEADERS_BY_CALLBACK.get(callback_name or "") + if builder is None: + return _NO_PROJECT_HEADERS + return builder(auth_metadata) + + __all__ = [ "DYNAMIC_HEADERS_BY_CALLBACK", "PRESET_BY_CALLBACK", + "PROJECT_HEADERS_BY_CALLBACK", "Preset", "agentops_preset", "arize_preset", @@ -73,5 +110,6 @@ __all__ = [ "langtrace_preset", "levo_preset", "phoenix_preset", + "project_routing_headers", "weave_preset", ] diff --git a/litellm/integrations/otel/presets/phoenix.py b/litellm/integrations/otel/presets/phoenix.py index fc1eb9f748f..eef407b6c1b 100644 --- a/litellm/integrations/otel/presets/phoenix.py +++ b/litellm/integrations/otel/presets/phoenix.py @@ -1,5 +1,7 @@ """Arize-Phoenix preset.""" +from collections.abc import Mapping +from types import MappingProxyType from typing import Final from pydantic import AliasChoices, Field @@ -25,6 +27,36 @@ class _PhoenixSettings(BaseSettings): ) +#: Phoenix routes an OTLP/HTTP export to a project by this header, which takes +#: precedence over the ``openinference.project.name`` resource attribute the env +#: var sets. Requires arize-phoenix 15.5.0+; older collectors ignore it and the +#: spans land in the resource attribute's project. +PHOENIX_PROJECT_HEADER: Final = "x-project-name" + +#: Key/team config fields naming the target project, highest precedence first. +_PROJECT_KEYS: Final = ("phoenix_project_name_override", "phoenix_project_name") + +_NO_PROJECT: Final[Mapping[str, str]] = MappingProxyType({}) + + +def phoenix_project_headers(auth_metadata: Mapping[str, str] | None) -> Mapping[str, str]: + """The per-request Phoenix project header for this key/team, if any. + + ``auth_metadata`` must be the key/team config the proxy resolved at auth + (``user_api_key_auth_metadata``), never client-supplied request metadata: + choosing the destination project is a data-exfiltration primitive, so a + caller must not be able to name one. Returns an empty mapping when the key + and team name no project, leaving the request on the env-configured default. + """ + if not auth_metadata: + return _NO_PROJECT + project: Final = next( + (stripped for key in _PROJECT_KEYS if (stripped := (auth_metadata.get(key) or "").strip())), + "", + ) + return MappingProxyType({PHOENIX_PROJECT_HEADER: project}) if project else _NO_PROJECT + + def phoenix_preset( *, config_overrides: OpenTelemetryV2Config | None = None, diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index c9f9c00f120..efc99321447 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -217,8 +217,10 @@ _SAFE_CLIENT_CALLBACK_PARAMS: Final[frozenset[str]] = frozenset( _EXTRA_BANNED_OBSERVABILITY_PARAMS: Final[frozenset[str]] = frozenset( { "posthog_api_url", - "phoenix_project_name", - "phoenix_project_name_override", + # ``phoenix_project_name`` / ``phoenix_project_name_override`` are NOT + # banned: on the proxy the Phoenix integrations only read them from + # ``user_api_key_auth_metadata`` (key/team config), so the bare request + # fields are inert and rejecting them just breaks SDK-style callers. # Server-reserved: written exclusively by add_user_api_key_auth_to_request_metadata # from the authenticated key's database record. A caller-supplied value # would survive the server merge and let an authenticated user redirect diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py b/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py index f7c0b5452fe..41a5e38be18 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py @@ -8,7 +8,11 @@ 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.presets import ( + dynamic_otlp_headers, + project_routing_headers, +) +from litellm.integrations.otel.plumbing.providers import parse_headers from litellm.integrations.otel.plumbing.routing import TenantTracerCache @@ -128,7 +132,7 @@ def test_dynamic_headers_applied_to_otlp_exporter_only(): ExporterSpec(kind="in_memory", owner="arize"), ], ) - new_cfg = cache._config_with_headers({"arize-space-id": "S", "api_key": "K"}) + new_cfg = cache._routed_config({"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 @@ -138,10 +142,9 @@ 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. + Regression for the cross-backend credential leak: the header rewrite used + to hit every OTLP exporter, so one request carrying a team's Arize key + clobbered the base collector's and Langfuse's headers with that key. """ cache = _cache( "arize", @@ -166,10 +169,131 @@ def test_dynamic_headers_do_not_leak_to_other_owners_exporter(): ), ], ) - new_cfg = cache._config_with_headers( - {"arize-space-id": "TEAMX", "api_key": "TEAMX_KEY"} + new_cfg = cache._routed_config( + {"arize-space-id": "TEAMX", "api_key": "TEAMX_KEY"}, {} ) 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" + + +# --- per-request Phoenix project routing from trusted key/team config --- # + + +def _phoenix_cache(kind="otlp_http"): + return _cache( + "arize_phoenix", + exporters=[ + ExporterSpec( + kind=kind, + endpoint="http://phoenix:6006", + headers="Authorization=Bearer phoenix-key", + owner="arize_phoenix", + ), + ], + ) + + +def test_phoenix_project_headers_precedence_and_blanks(): + assert project_routing_headers( + "arize_phoenix", {"phoenix_project_name": "team-proj"} + ) == {"x-project-name": "team-proj"} + assert project_routing_headers( + "arize_phoenix", + {"phoenix_project_name_override": "override", "phoenix_project_name": "base"}, + ) == {"x-project-name": "override"} + assert ( + project_routing_headers("arize_phoenix", {"phoenix_project_name": " "}) == {} + ) + assert project_routing_headers("arize_phoenix", None) == {} + # Only Phoenix participates in project routing. + assert project_routing_headers("arize", {"phoenix_project_name": "p"}) == {} + + +def test_project_header_appends_and_preserves_phoenix_auth(): + """Regression: routing to a project must not drop the preset's static + ``Authorization`` header — a replace would break Phoenix auth entirely.""" + cache = _phoenix_cache() + cfg = cache._routed_config({}, {"x-project-name": "team-proj"}) + (spec,) = cfg.exporters + parsed = parse_headers(spec.headers) + assert parsed["authorization"] == "Bearer phoenix-key" + assert parsed["x-project-name"] == "team-proj" + + +def test_project_name_with_header_separators_round_trips(): + cache = _phoenix_cache() + cfg = cache._routed_config({}, {"x-project-name": "my proj, prod=1"}) + (spec,) = cfg.exporters + parsed = parse_headers(spec.headers) + assert parsed["x-project-name"] == "my proj, prod=1" + assert parsed["authorization"] == "Bearer phoenix-key" + + +def test_project_header_does_not_touch_other_exporters(): + cache = _cache( + "arize_phoenix", + exporters=[ + ExporterSpec( + kind="otlp_http", + endpoint="http://collector:4318", + headers="x=base-collector", + owner=None, + ), + ExporterSpec( + kind="otlp_http", + endpoint="http://phoenix:6006", + headers="Authorization=Bearer phoenix-key", + owner="arize_phoenix", + ), + ], + ) + cfg = cache._routed_config({}, {"x-project-name": "team-proj"}) + by_owner = {e.owner: e.headers for e in cfg.exporters} + assert by_owner[None] == "x=base-collector" + assert parse_headers(by_owner["arize_phoenix"])["x-project-name"] == "team-proj" + + +def test_provider_cached_per_project(): + cache = _phoenix_cache() + default = NoOpTracer() + routed = cache.tracer_for(default, None, {"phoenix_project_name": "proj-a"}) + assert routed is not default + cache.tracer_for(default, None, {"phoenix_project_name": "proj-a"}) + assert len(cache._providers) == 1 + cache.tracer_for(default, None, {"phoenix_project_name": "proj-b"}) + assert len(cache._providers) == 2 + for provider in cache._providers.values(): + provider.shutdown() + + +def test_client_dynamic_params_cannot_choose_phoenix_project(): + # ``StandardCallbackDynamicParams`` is populated from client-supplied + # request metadata; the project may only come from server-set key/team + # config (the ``auth_metadata`` argument). + cache = _phoenix_cache() + default = NoOpTracer() + assert cache.tracer_for(default, {"phoenix_project_name": "attacker"}) is default + assert ( + cache.tracer_for(default, {"phoenix_project_name_override": "attacker"}) + is default + ) + assert cache._providers == {} + + +def test_auth_metadata_without_project_uses_default_tracer(): + cache = _phoenix_cache() + default = NoOpTracer() + assert cache.tracer_for(default, None, {"logging_setting": "x"}) is default + assert cache._providers == {} + + +def test_grpc_exporter_gets_no_project_routing(): + # ``x-project-name`` is only honored on the OTLP/HTTP endpoint, so a + # gRPC-only Phoenix exporter stays on the default project (warned once). + cache = _phoenix_cache(kind="otlp_grpc") + default = NoOpTracer() + assert cache.tracer_for(default, None, {"phoenix_project_name": "proj"}) is default + assert cache._providers == {} + assert cache._warned_project_unroutable is True diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py index 82b074220fa..f077f58f209 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -35,6 +35,7 @@ from litellm.integrations.otel.plumbing.context import ( # noqa: E402 set_request_root_span, ) from litellm.integrations.otel.logger import OpenTelemetryV2 # noqa: E402 +from litellm.integrations.otel.model.config import ExporterSpec # noqa: E402 from litellm.integrations.otel.model.spans import ( # noqa: E402 LITELLM_PROXY_REQUEST_SPAN_NAME, SpanRole, @@ -2277,3 +2278,110 @@ def test_metrics_disabled_by_default_records_nothing(monkeypatch): ) ) assert _emitted_metric_names(reader) == set() + + +# --------------------------------------------------------------------------- # +# Per-request Phoenix project routing (key/team auth metadata) +# --------------------------------------------------------------------------- # + + +def _phoenix_routing_logger(capture_kind): + """A Phoenix-shaped logger whose owned exporter is a registered factory kind + that captures the exporter built per routed header set, so the test can + assert which destination each span actually exported through.""" + captured = {} + + def factory(spec): + exporter = InMemorySpanExporter() + captured[spec.headers] = exporter + return exporter + + providers.register_exporter_factory(capture_kind, factory) + cfg = OpenTelemetryV2Config( + exporters=[ + ExporterSpec( + kind=capture_kind, + endpoint="http://phoenix:6006", + headers="Authorization=Bearer phoenix-key", + owner="arize_phoenix", + ) + ] + ) + default_exporter = InMemorySpanExporter() + tracer_provider = providers.build_tracer_provider(cfg, exporter=default_exporter) + logger = OpenTelemetryV2( + config=cfg, callback_name="arize_phoenix", tracer_provider=tracer_provider + ) + return logger, default_exporter, captured + + +def test_key_team_auth_metadata_routes_llm_span_to_phoenix_project(): + """The proxy stamps the key/team config into ``user_api_key_auth_metadata``; + a ``phoenix_project_name`` there must route the LLM span through an exporter + carrying the ``x-project-name`` header while keeping the preset's auth.""" + logger, default_exporter, captured = _phoenix_routing_logger("capture_route_a") + auth_md = {"phoenix_project_name": "team-proj"} + payload = _payload(metadata={"user_api_key_auth_metadata": auth_md}) + kwargs = { + "standard_logging_object": payload, + "litellm_params": {"metadata": {"user_api_key_auth_metadata": auth_md}}, + } + _emit_llm(logger, kwargs) + + assert [s.name for s in default_exporter.get_finished_spans()] == [] + (headers,) = captured + parsed = providers.parse_headers(headers) + assert parsed["x-project-name"] == "team-proj" + assert parsed["authorization"] == "Bearer phoenix-key" + routed_spans = captured[headers].get_finished_spans() + assert len(routed_spans) == 1 + + +def test_client_request_metadata_cannot_route_phoenix_project(): + """A bare ``phoenix_project_name`` in client request metadata (not the + server-set ``user_api_key_auth_metadata``) must be ignored: the span stays + on the default tracer and no routed exporter is ever built.""" + logger, default_exporter, captured = _phoenix_routing_logger("capture_route_b") + payload = _payload(metadata={"phoenix_project_name": "attacker-project"}) + kwargs = { + "standard_logging_object": payload, + "litellm_params": {"metadata": {"phoenix_project_name": "attacker-project"}}, + } + _emit_llm(logger, kwargs) + + assert captured == {} + assert len(default_exporter.get_finished_spans()) == 1 + + +def test_project_routing_resolves_at_pre_call_before_payload_exists(): + """Production ``pre_call`` runs before the standard logging payload exists, + so the destination project must resolve from ``litellm_params`` alone — the + span is created (and its exporter chosen) right there.""" + logger, default_exporter, captured = _phoenix_routing_logger("capture_route_c") + auth_md = {"phoenix_project_name": "team-proj"} + litellm_params = {"metadata": {"user_api_key_auth_metadata": auth_md}} + server = logger._emitter.start_span( + SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME + ) + with trace.use_span(server, end_on_exit=False): + logger.log_pre_api_call( + model="gpt-4o", + messages=[], + kwargs={"litellm_call_id": "call_1", "litellm_params": litellm_params}, + ) + server.end() + assert len(captured) == 1 # routed exporter already built at pre_call + + close_kwargs = { + "standard_logging_object": _payload( + metadata={"user_api_key_auth_metadata": auth_md} + ), + "litellm_params": litellm_params, + } + asyncio.run(logger.async_log_success_event(close_kwargs, None, None, None)) + + (headers,) = captured + assert [s.name for s in captured[headers].get_finished_spans()] == ["chat gpt-4o"] + assert all( + s.name != "chat gpt-4o" for s in default_exporter.get_finished_spans() + ) diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 5becd05b8e8..3102e69bf26 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -2653,8 +2653,6 @@ class TestObservabilityCallbackBans: "posthog_api_url", "braintrust_api_key", "braintrust_project", - "phoenix_project_name", - "phoenix_project_name_override", "wandb_api_key", "weave_project_id", "gcs_bucket_name", @@ -2685,8 +2683,7 @@ class TestObservabilityCallbackBans: "langsmith_api_key", "posthog_api_url", "braintrust_project", - "phoenix_project_name", - "phoenix_project_name_override", + "user_api_key_auth_metadata", ], ) def test_observability_field_in_metadata_dict_is_rejected( @@ -2707,6 +2704,28 @@ class TestObservabilityCallbackBans: ) assert field in str(exc.value) + @pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) + @pytest.mark.parametrize( + "field", + ["phoenix_project_name", "phoenix_project_name_override"], + ) + def test_phoenix_project_fields_in_metadata_are_accepted(self, metadata_key, field): + # The Phoenix integrations only honor the project from + # ``user_api_key_auth_metadata`` on the proxy, so the bare metadata + # fields are inert and must not 400 SDK-style callers that send them. + assert ( + is_request_body_safe( + request_body={ + "model": "gpt-4", + metadata_key: {field: "client-project"}, + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + is True + ) + def test_observability_field_in_litellm_params_metadata_is_rejected(self): with pytest.raises(ValueError) as exc: is_request_body_safe(