mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +00:00
Merge 3cd585ee29 into 4786dd861b
This commit is contained in:
commit
01aaf55168
6 changed files with 170 additions and 2 deletions
|
|
@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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_<control>`` 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_<control>`` 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"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue