diff --git a/litellm/integrations/otel/mappers/langfuse.py b/litellm/integrations/otel/mappers/langfuse.py index 5aed173254c..09d7c7d20a6 100644 --- a/litellm/integrations/otel/mappers/langfuse.py +++ b/litellm/integrations/otel/mappers/langfuse.py @@ -50,6 +50,15 @@ def _identity_field(name: str) -> Callable[[LLMCallSpanData], AttrValue | None]: return lambda d: d.request_metadata.get(name) +# Per-request trace/release metadata Langfuse v4 reads off the span. ``langfuse.environment`` +# is the documented observation- and trace-level environment key (takes precedence over +# ``deployment.environment``); ``langfuse.release`` / ``langfuse.version`` map to the trace +# release and observation version respectively. +LANGFUSE_ENVIRONMENT: Final = "langfuse.environment" +LANGFUSE_RELEASE: Final = "langfuse.release" +LANGFUSE_VERSION: Final = "langfuse.version" + + class LangfuseMapper: _LLM_CALL_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = { "langfuse.observation.type": lambda _: "generation", @@ -130,6 +139,9 @@ class LangfuseMapper: (LANGFUSE_TRACE_USER_ID, trace.user_id or None), (LANGFUSE_TRACE_SESSION_ID, trace.session_id or None), (LANGFUSE_TRACE_TAGS, trace.tags or None), + (LANGFUSE_ENVIRONMENT, trace.environment or None), + (LANGFUSE_RELEASE, trace.release or None), + (LANGFUSE_VERSION, trace.version or None), ) ) diff --git a/litellm/integrations/otel/model/trace_controls.py b/litellm/integrations/otel/model/trace_controls.py index eac7b5c897b..0562da9963f 100644 --- a/litellm/integrations/otel/model/trace_controls.py +++ b/litellm/integrations/otel/model/trace_controls.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from collections.abc import Mapping from dataclasses import dataclass from typing import Final @@ -13,18 +14,30 @@ from litellm.integrations.otel.model.utils import as_str, as_str_mapping LANGFUSE_HEADER_PREFIX: Final = "langfuse_" _ITEMS: Final = TypeAdapter(tuple[object, ...]) +# Deployment-wide tracing environment fallback, mirroring the legacy +# ``langfuse_otel`` logger and the ``LANGFUSE_TRACING_ENVIRONMENT`` env var the +# proxy sets per-stage. Read at call time so per-request overrides win. +LANGFUSE_TRACING_ENVIRONMENT: Final = "LANGFUSE_TRACING_ENVIRONMENT" + @dataclass(frozen=True, slots=True) class TraceControls: """The caller's trace-level Langfuse controls: ``metadata.trace_name`` / ``trace_user_id`` / ``session_id`` / ``tags`` on the request (SDK or proxy body), with the proxy's ``langfuse_`` headers winning over the body for the scalar ones. Mutation controls (``trace_id``, ``existing_trace_id``, ``update_trace_keys``) are - deliberately not carried.""" + deliberately not carried. + + ``environment`` / ``release`` / ``version`` let a caller tag each trace with its own deployment + stage and release identifiers. ``environment`` falls back to the gateway's ``LANGFUSE_TRACING_ENVIRONMENT`` + when the request does not set one, preserving the per-stage behaviour the legacy logger provided.""" name: str | None = None user_id: str | None = None session_id: str | None = None tags: tuple[str, ...] = () + environment: str | None = None + release: str | None = None + version: str | None = None def caller_trace_controls(kwargs: Mapping[str, object]) -> TraceControls: @@ -45,11 +58,36 @@ def caller_trace_controls(kwargs: Mapping[str, object]) -> TraceControls: return from_header return next((value for body in bodies if (value := as_str(body.get(control)))), None) + def body_scalar(*controls: str) -> str | None: + """First non-empty value across the request metadata bodies for any of ``controls``.""" + for control in controls: + value = next((v for body in bodies if (v := as_str(body.get(control)))), None) + if value: + return value + return None + + def header_scalar(*controls: str) -> str | None: + """First non-empty ``langfuse_`` request header for any of ``controls``.""" + if headers is None: + return None + for control in controls: + value = as_str(headers.get(f"{LANGFUSE_HEADER_PREFIX}{control}")) + if value: + return value + return None + return TraceControls( name=scalar("trace_name"), user_id=scalar("trace_user_id"), session_id=scalar("session_id"), tags=next((tags for body in bodies if (tags := _str_items(body.get("tags")))), ()), + environment=( + header_scalar("environment") + or body_scalar("langfuse_environment") + or os.environ.get(LANGFUSE_TRACING_ENVIRONMENT) + ), + release=header_scalar("release") or body_scalar("trace_release", "release"), + version=header_scalar("version") or body_scalar("trace_version", "version"), ) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 9350c9c935c..67c7e47ae5d 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -508,6 +508,21 @@ def _check_banned_params( # Proxy-wide opt-in: every banned param is permitted, exit # entirely so the rest of the loop doesn't waste work. return + if ( + _is_param_allowed( + param=param, + request_body_value=body[param], + configurable_clientside_auth_params=general_settings.get("configurable_clientside_auth_params"), + ) + is True + ): + # Proxy-wide SELECTIVE opt-in: only the params the admin named in + # ``general_settings.configurable_clientside_auth_params`` are + # permitted, for EVERY model. Unlike ``allow_client_side_credentials`` + # (which lifts the whole blocklist), this admits only the listed + # params, so a client that may set ``langfuse_environment`` still + # cannot smuggle ``api_base``/``aws_web_identity_token``/etc. + continue if ( _allow_model_level_clientside_configurable_parameters( model=model, diff --git a/tests/unit/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/unit/integrations/otel/test_otel_v2_sources_of_truth.py index 2d0b4701c9f..3502fa2f05f 100644 --- a/tests/unit/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/unit/integrations/otel/test_otel_v2_sources_of_truth.py @@ -1359,6 +1359,56 @@ def test_caller_trace_controls_carry_user_session_and_tags(request_data, expecte assert LLMCallEvent.from_dict({"litellm_params": request_data}).trace == expected +def test_caller_trace_controls_carry_environment_release_and_version(monkeypatch): + """Caller-supplied environment, release, and version are mapped from headers or request metadata, + falling back to LANGFUSE_TRACING_ENVIRONMENT env var.""" + # 1. Header precedence over metadata + from_headers: Final = caller_trace_controls( + { + "litellm_params": { + "proxy_server_request": { + "headers": { + "langfuse_environment": "prod-env", + "langfuse_release": "rel-v2", + "langfuse_version": "ver-3", + } + }, + "metadata": { + "langfuse_environment": "staging-env", + "trace_release": "rel-v1", + "trace_version": "ver-2", + }, + } + } + ) + assert from_headers.environment == "prod-env" + assert from_headers.release == "rel-v2" + assert from_headers.version == "ver-3" + + # 2. Metadata body precedence over env var fallback + monkeypatch.setenv("LANGFUSE_TRACING_ENVIRONMENT", "env-fallback") + from_body: Final = caller_trace_controls( + { + "litellm_params": { + "metadata": { + "langfuse_environment": "meta-env", + "trace_release": "rel-v1", + "trace_version": "ver-1", + } + } + } + ) + assert from_body.environment == "meta-env" + assert from_body.release == "rel-v1" + assert from_body.version == "ver-1" + + # 3. Environment variable fallback + from_env: Final = caller_trace_controls({"litellm_params": {}}) + assert from_env.environment == "env-fallback" + assert from_env.release is None + assert from_env.version is None + + def test_llm_span_data_carries_the_caller_trace_controls(): controls: Final = TraceControls(name="nightly-eval", user_id="u1", session_id="s1", tags=("a", "b")) data: Final = LLMCallSpanData.from_standard_logging_payload(_sample_payload(), trace=controls) diff --git a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py index 57764bf902b..56042fcb3a4 100644 --- a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py @@ -628,7 +628,32 @@ def test_langfuse_mapper_carries_the_caller_user_session_and_tags(): def test_langfuse_mapper_omits_unset_trace_controls(): attrs = LangfuseMapper().map(_llm_call(trace=TraceControls(user_id="", session_id=None, tags=()))) - assert {"user.id", "session.id", "langfuse.trace.tags", "langfuse.trace.name"}.isdisjoint(attrs) + assert { + "user.id", + "session.id", + "langfuse.trace.tags", + "langfuse.trace.name", + "langfuse.environment", + "langfuse.release", + "langfuse.version", + }.isdisjoint(attrs) + + +def test_langfuse_mapper_carries_environment_release_and_version(): + controls: Final = TraceControls( + environment="staging", + release="commit-abc123", + version="sha256-deadbeef", + ) + attrs: Final = LangfuseMapper().map(_llm_call(trace=controls)) + assert attrs["langfuse.environment"] == "staging" + assert attrs["langfuse.release"] == "commit-abc123" + assert attrs["langfuse.version"] == "sha256-deadbeef" + + root: Final = LangfuseMapper.trace_attributes(controls) + assert root["langfuse.environment"] == "staging" + assert root["langfuse.release"] == "commit-abc123" + assert root["langfuse.version"] == "sha256-deadbeef" def test_langfuse_trace_attributes_match_between_root_and_generation(): diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 8612351eda6..fc13b9d8ffc 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -4471,6 +4471,34 @@ def test_model_level_allow_does_not_skip_subsequent_banned_params(monkeypatch): assert "langfuse_host" in str(exc.value) +def test_proxy_wide_selective_configurable_clientside_auth_params(): + """Proxy-wide selective opt-in via general_settings.configurable_clientside_auth_params admits + named params for every model without opening unlisted banned params.""" + # When langfuse_environment is admitted proxy-wide: + is_request_body_safe( + request_body={ + "model": "gpt-4", + "langfuse_environment": "staging", + }, + general_settings={"configurable_clientside_auth_params": ["langfuse_environment"]}, + llm_router=None, + model="gpt-4", + ) + + # But api_base remains banned: + with pytest.raises(ValueError, match="Rejected Request: api_base is not allowed in request body"): + is_request_body_safe( + request_body={ + "model": "gpt-4", + "langfuse_environment": "staging", + "api_base": "https://attacker.example", + }, + general_settings={"configurable_clientside_auth_params": ["langfuse_environment"]}, + llm_router=None, + model="gpt-4", + ) + + def test_observability_ban_covers_canonical_supported_callback_params(): """Guard test: every entry in the canonical ``_supported_callback_params`` allow-list must end up either banned by