mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-16 22:17:21 +00:00
Merge branch 'litellm_oss_staging_02_04_2026' into ttl-prompt-caching-bedrock
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
# LiteLLM Makefile
|
||||
# Simple Makefile for running tests and basic development tasks
|
||||
|
||||
.PHONY: help test test-unit test-integration test-unit-helm lint format install-dev install-proxy-dev install-test-deps install-helm-unittest check-circular-imports check-import-safety
|
||||
.PHONY: help test test-unit test-integration test-unit-helm \
|
||||
info lint lint-dev format \
|
||||
install-dev install-proxy-dev install-test-deps \
|
||||
install-helm-unittest check-circular-imports check-import-safety
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -25,6 +28,13 @@ help:
|
||||
@echo " make test-integration - Run integration tests"
|
||||
@echo " make test-unit-helm - Run helm unit tests"
|
||||
|
||||
# Keep PIP simple for edge cases:
|
||||
PIP := $(shell command -v pip > /dev/null 2>&1 && echo "pip" || echo "python3 -m pip")
|
||||
|
||||
# Show info
|
||||
info:
|
||||
@echo "PIP: $(PIP)"
|
||||
|
||||
# Installation targets
|
||||
install-dev:
|
||||
poetry install --with dev
|
||||
@@ -34,19 +44,19 @@ install-proxy-dev:
|
||||
|
||||
# CI-compatible installations (matches GitHub workflows exactly)
|
||||
install-dev-ci:
|
||||
pip install openai==2.8.0
|
||||
$(PIP) install openai==2.8.0
|
||||
poetry install --with dev
|
||||
pip install openai==2.8.0
|
||||
$(PIP) install openai==2.8.0
|
||||
|
||||
install-proxy-dev-ci:
|
||||
poetry install --with dev,proxy-dev --extras proxy
|
||||
pip install openai==2.8.0
|
||||
$(PIP) install openai==2.8.0
|
||||
|
||||
install-test-deps: install-proxy-dev
|
||||
poetry run pip install "pytest-retry==1.6.3"
|
||||
poetry run pip install pytest-xdist
|
||||
poetry run pip install openapi-core
|
||||
cd enterprise && poetry run pip install -e . && cd ..
|
||||
poetry run $(PIP) install "pytest-retry==1.6.3"
|
||||
poetry run $(PIP) install pytest-xdist
|
||||
poetry run $(PIP) install openapi-core
|
||||
cd enterprise && poetry run $(PIP) install -e . && cd ..
|
||||
|
||||
install-helm-unittest:
|
||||
helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.4.4 || echo "ignore error if plugin exists"
|
||||
@@ -62,8 +72,40 @@ format-check: install-dev
|
||||
lint-ruff: install-dev
|
||||
cd litellm && poetry run ruff check . && cd ..
|
||||
|
||||
# faster linter for developing ...
|
||||
# inspiration from:
|
||||
# https://github.com/astral-sh/ruff/discussions/10977
|
||||
# https://github.com/astral-sh/ruff/discussions/4049
|
||||
lint-format-changed: install-dev
|
||||
@git diff origin/main --unified=0 --no-color -- '*.py' | \
|
||||
perl -ne '\
|
||||
if (/^diff --git a\/(.*) b\//) { $$file = $$1; } \
|
||||
if (/^@@ .* \+(\d+)(?:,(\d+))? @@/) { \
|
||||
$$start = $$1; $$count = $$2 || 1; $$end = $$start + $$count - 1; \
|
||||
print "$$file:$$start:1-$$end:999\n"; \
|
||||
}' | \
|
||||
while read range; do \
|
||||
file="$${range%%:*}"; \
|
||||
lines="$${range#*:}"; \
|
||||
echo "Formatting $$file (lines $$lines)"; \
|
||||
poetry run ruff format --range "$$lines" "$$file"; \
|
||||
done
|
||||
|
||||
lint-ruff-dev: install-dev
|
||||
@tmpfile=$$(mktemp /tmp/ruff-dev.XXXXXX) && \
|
||||
cd litellm && \
|
||||
(poetry run ruff check . --output-format=pylint || true) > "$$tmpfile" && \
|
||||
poetry run diff-quality --violations=pylint "$$tmpfile" --compare-branch=origin/main && \
|
||||
cd .. ; \
|
||||
rm -f "$$tmpfile"
|
||||
|
||||
lint-ruff-FULL-dev: install-dev
|
||||
@files=$$(git diff --name-only origin/main -- '*.py'); \
|
||||
if [ -n "$$files" ]; then echo "$$files" | xargs poetry run ruff check; \
|
||||
else echo "No changed .py files to check."; fi
|
||||
|
||||
lint-mypy: install-dev
|
||||
poetry run pip install types-requests types-setuptools types-redis types-PyYAML
|
||||
poetry run $(PIP) install types-requests types-setuptools types-redis types-PyYAML
|
||||
cd litellm && poetry run mypy . --ignore-missing-imports && cd ..
|
||||
|
||||
lint-black: format-check
|
||||
@@ -72,11 +114,14 @@ check-circular-imports: install-dev
|
||||
cd litellm && poetry run python ../tests/documentation_tests/test_circular_imports.py && cd ..
|
||||
|
||||
check-import-safety: install-dev
|
||||
poetry run python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
|
||||
@poetry run python -c "from litellm import *; print('[from litellm import *] OK! no issues!');" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
|
||||
|
||||
# Combined linting (matches test-linting.yml workflow)
|
||||
lint: format-check lint-ruff lint-mypy check-circular-imports check-import-safety
|
||||
|
||||
# Faster linting for local development (only checks changed code)
|
||||
lint-dev: lint-format-changed lint-mypy check-circular-imports check-import-safety
|
||||
|
||||
# Testing targets
|
||||
test:
|
||||
poetry run pytest tests/
|
||||
|
||||
@@ -8,9 +8,8 @@ from litellm.integrations.arize import _utils
|
||||
from litellm.integrations.langfuse.langfuse_otel_attributes import (
|
||||
LangfuseLLMObsOTELAttributes,
|
||||
)
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
||||
from litellm.types.integrations.langfuse_otel import (
|
||||
LangfuseOtelConfig,
|
||||
LangfuseSpanAttributes,
|
||||
)
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
@@ -18,17 +17,8 @@ from litellm.types.utils import StandardCallbackDynamicParams
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span as _Span
|
||||
|
||||
from litellm.integrations.opentelemetry import (
|
||||
OpenTelemetryConfig as _OpenTelemetryConfig,
|
||||
)
|
||||
from litellm.types.integrations.arize import Protocol as _Protocol
|
||||
|
||||
Protocol = _Protocol
|
||||
OpenTelemetryConfig = _OpenTelemetryConfig
|
||||
Span = Union[_Span, Any]
|
||||
else:
|
||||
Protocol = Any
|
||||
OpenTelemetryConfig = Any
|
||||
Span = Any
|
||||
|
||||
|
||||
@@ -37,8 +27,12 @@ LANGFUSE_CLOUD_US_ENDPOINT = "https://us.cloud.langfuse.com/api/public/otel"
|
||||
|
||||
|
||||
class LangfuseOtelLogger(OpenTelemetry):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
def __init__(self, config=None, *args, **kwargs):
|
||||
# Prevent LangfuseOtelLogger from modifying global environment variables by constructing config manually
|
||||
# and passing it to the parent OpenTelemetry class
|
||||
if config is None:
|
||||
config = self._create_open_telemetry_config_from_langfuse_env()
|
||||
super().__init__(config=config, *args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def set_langfuse_otel_attributes(span: Span, kwargs, response_obj):
|
||||
@@ -114,6 +108,10 @@ class LangfuseOtelLogger(OpenTelemetry):
|
||||
for key, enum_attr in mapping.items():
|
||||
if key in metadata and metadata[key] is not None:
|
||||
value = metadata[key]
|
||||
if key == "trace_id" and isinstance(value, str):
|
||||
# trace_id must be 32 hex char no dashes for langfuse : Litellm sends uuid with dashes (might be breaking at some point)
|
||||
value = value.replace("-", "")
|
||||
|
||||
if isinstance(value, (list, dict)):
|
||||
try:
|
||||
value = json.dumps(value)
|
||||
@@ -265,8 +263,47 @@ class LangfuseOtelLogger(OpenTelemetry):
|
||||
"""
|
||||
return os.environ.get("LANGFUSE_OTEL_HOST") or os.environ.get("LANGFUSE_HOST")
|
||||
|
||||
def _create_open_telemetry_config_from_langfuse_env(self) -> OpenTelemetryConfig:
|
||||
"""
|
||||
Creates OpenTelemetryConfig from Langfuse environment variables.
|
||||
Does NOT modify global environment variables.
|
||||
"""
|
||||
from litellm.integrations.opentelemetry import OpenTelemetryConfig
|
||||
|
||||
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY", None)
|
||||
secret_key = os.environ.get("LANGFUSE_SECRET_KEY", None)
|
||||
|
||||
if not public_key or not secret_key:
|
||||
# If no keys, return default from env (likely logging to console or something else)
|
||||
return OpenTelemetryConfig.from_env()
|
||||
|
||||
# Determine endpoint - default to US cloud
|
||||
langfuse_host = LangfuseOtelLogger._get_langfuse_otel_host()
|
||||
|
||||
if langfuse_host:
|
||||
# If LANGFUSE_HOST is provided, construct OTEL endpoint from it
|
||||
if not langfuse_host.startswith("http"):
|
||||
langfuse_host = "https://" + langfuse_host
|
||||
endpoint = f"{langfuse_host.rstrip('/')}/api/public/otel"
|
||||
verbose_logger.debug(f"Using Langfuse OTEL endpoint from host: {endpoint}")
|
||||
else:
|
||||
# Default to US cloud endpoint
|
||||
endpoint = LANGFUSE_CLOUD_US_ENDPOINT
|
||||
verbose_logger.debug(f"Using Langfuse US cloud endpoint: {endpoint}")
|
||||
|
||||
auth_header = LangfuseOtelLogger._get_langfuse_authorization_header(
|
||||
public_key=public_key, secret_key=secret_key
|
||||
)
|
||||
otlp_auth_headers = f"Authorization={auth_header}"
|
||||
|
||||
return OpenTelemetryConfig(
|
||||
exporter="otlp_http",
|
||||
endpoint=endpoint,
|
||||
headers=otlp_auth_headers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_langfuse_otel_config() -> LangfuseOtelConfig:
|
||||
def get_langfuse_otel_config() -> "OpenTelemetryConfig":
|
||||
"""
|
||||
Retrieves the Langfuse OpenTelemetry configuration based on environment variables.
|
||||
|
||||
@@ -276,7 +313,7 @@ class LangfuseOtelLogger(OpenTelemetry):
|
||||
LANGFUSE_HOST: Optional. Custom Langfuse host URL. Defaults to US cloud.
|
||||
|
||||
Returns:
|
||||
LangfuseOtelConfig: A Pydantic model containing Langfuse OTEL configuration.
|
||||
OpenTelemetryConfig: A Pydantic model containing Langfuse OTEL configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If required keys are missing.
|
||||
@@ -308,12 +345,14 @@ class LangfuseOtelLogger(OpenTelemetry):
|
||||
)
|
||||
otlp_auth_headers = f"Authorization={auth_header}"
|
||||
|
||||
# Set standard OTEL environment variables
|
||||
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint
|
||||
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = otlp_auth_headers
|
||||
# Prevent modification of global env vars which causes leakage
|
||||
# os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint
|
||||
# os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = otlp_auth_headers
|
||||
|
||||
return LangfuseOtelConfig(
|
||||
otlp_auth_headers=otlp_auth_headers, protocol="otlp_http"
|
||||
return OpenTelemetryConfig(
|
||||
exporter="otlp_http",
|
||||
endpoint=endpoint,
|
||||
headers=otlp_auth_headers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1010,9 +1010,9 @@ class OpenTelemetry(CustomLogger):
|
||||
from opentelemetry._logs import SeverityNumber, get_logger, get_logger_provider
|
||||
|
||||
try:
|
||||
from opentelemetry.sdk._logs import LogRecord as SdkLogRecord # type: ignore[attr-defined] # OTEL < 1.39.0
|
||||
from opentelemetry.sdk._logs import LogRecord as SdkLogRecord # type: ignore[attr-defined] # OTEL < 1.39.0
|
||||
except ImportError:
|
||||
from opentelemetry.sdk._logs._internal import LogRecord as SdkLogRecord # type: ignore[attr-defined, no-redef] # OTEL >= 1.39.0
|
||||
from opentelemetry.sdk._logs._internal import LogRecord as SdkLogRecord # type: ignore[attr-defined, no-redef] # OTEL >= 1.39.0
|
||||
|
||||
otel_logger = get_logger(LITELLM_LOGGER_NAME)
|
||||
|
||||
@@ -1725,6 +1725,7 @@ class OpenTelemetry(CustomLogger):
|
||||
|
||||
def set_raw_request_attributes(self, span: Span, kwargs, response_obj):
|
||||
try:
|
||||
self.set_attributes(span, kwargs, response_obj)
|
||||
kwargs.get("optional_params", {})
|
||||
litellm_params = kwargs.get("litellm_params", {}) or {}
|
||||
custom_llm_provider = litellm_params.get("custom_llm_provider", "Unknown")
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
from typing import Dict, Optional
|
||||
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
# Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict
|
||||
_supported_callback_params = [
|
||||
"langfuse_public_key",
|
||||
"langfuse_secret",
|
||||
"langfuse_secret_key",
|
||||
"langfuse_host",
|
||||
"langfuse_prompt_version",
|
||||
"gcs_bucket_name",
|
||||
"gcs_path_service_account",
|
||||
"langsmith_api_key",
|
||||
"langsmith_project",
|
||||
"langsmith_base_url",
|
||||
"langsmith_sampling_rate",
|
||||
"langsmith_tenant_id",
|
||||
"humanloop_api_key",
|
||||
"arize_api_key",
|
||||
"arize_space_key",
|
||||
"arize_space_id",
|
||||
"posthog_api_key",
|
||||
"posthog_host",
|
||||
"braintrust_api_key",
|
||||
"braintrust_project",
|
||||
"braintrust_host",
|
||||
"slack_webhook_url",
|
||||
"lunary_public_key",
|
||||
]
|
||||
|
||||
|
||||
def initialize_standard_callback_dynamic_params(
|
||||
kwargs: Optional[Dict] = None,
|
||||
@@ -15,13 +41,10 @@ def initialize_standard_callback_dynamic_params(
|
||||
|
||||
standard_callback_dynamic_params = StandardCallbackDynamicParams()
|
||||
if kwargs:
|
||||
_supported_callback_params = (
|
||||
StandardCallbackDynamicParams.__annotations__.keys()
|
||||
)
|
||||
|
||||
# 1. Check top-level kwargs
|
||||
for param in _supported_callback_params:
|
||||
if param in kwargs:
|
||||
_param_value = kwargs.pop(param)
|
||||
_param_value = kwargs.get(param)
|
||||
if (
|
||||
_param_value is not None
|
||||
and isinstance(_param_value, str)
|
||||
@@ -30,4 +53,22 @@ def initialize_standard_callback_dynamic_params(
|
||||
_param_value = get_secret_str(secret_name=_param_value)
|
||||
standard_callback_dynamic_params[param] = _param_value # type: ignore
|
||||
|
||||
# 2. Fallback: check "metadata" or "litellm_params" -> "metadata"
|
||||
metadata = (kwargs.get("metadata") or {}).copy()
|
||||
litellm_params = kwargs.get("litellm_params") or {}
|
||||
if isinstance(litellm_params, dict):
|
||||
metadata.update(litellm_params.get("metadata") or {})
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
for param in _supported_callback_params:
|
||||
if param not in standard_callback_dynamic_params and param in metadata:
|
||||
_param_value = metadata.get(param)
|
||||
if (
|
||||
_param_value is not None
|
||||
and isinstance(_param_value, str)
|
||||
and "os.environ/" in _param_value
|
||||
):
|
||||
_param_value = get_secret_str(secret_name=_param_value)
|
||||
standard_callback_dynamic_params[param] = _param_value # type: ignore
|
||||
|
||||
return standard_callback_dynamic_params
|
||||
|
||||
@@ -3917,18 +3917,6 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
||||
return langfuse_logger # type: ignore
|
||||
elif logging_integration == "langfuse_otel":
|
||||
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
|
||||
from litellm.integrations.opentelemetry import (
|
||||
OpenTelemetry,
|
||||
OpenTelemetryConfig,
|
||||
)
|
||||
|
||||
langfuse_otel_config = LangfuseOtelLogger.get_langfuse_otel_config()
|
||||
|
||||
# The endpoint and headers are now set as environment variables by get_langfuse_otel_config()
|
||||
otel_config = OpenTelemetryConfig(
|
||||
exporter=langfuse_otel_config.protocol,
|
||||
headers=langfuse_otel_config.otlp_auth_headers,
|
||||
)
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if (
|
||||
@@ -3936,8 +3924,10 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
||||
and callback.callback_name == "langfuse_otel"
|
||||
):
|
||||
return callback # type: ignore
|
||||
# Allow LangfuseOtelLogger to initialize its own config safely
|
||||
# This prevents startup crashes if LANGFUSE keys are not in env (e.g. for dynamic usage)
|
||||
_otel_logger = LangfuseOtelLogger(
|
||||
config=otel_config, callback_name="langfuse_otel"
|
||||
config=None, callback_name="langfuse_otel"
|
||||
)
|
||||
_in_memory_loggers.append(_otel_logger)
|
||||
return _otel_logger # type: ignore
|
||||
|
||||
@@ -386,33 +386,7 @@ class GigaChatConfig(BaseConfig):
|
||||
|
||||
transformed.append(message)
|
||||
|
||||
# Collapse consecutive user messages
|
||||
return self._collapse_user_messages(transformed)
|
||||
|
||||
def _collapse_user_messages(self, messages: List[dict]) -> List[dict]:
|
||||
"""Collapse consecutive user messages into one."""
|
||||
collapsed: List[dict] = []
|
||||
prev_user_msg: Optional[dict] = None
|
||||
content_parts: List[str] = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("role") == "user" and prev_user_msg is not None:
|
||||
content_parts.append(msg.get("content", ""))
|
||||
else:
|
||||
if content_parts and prev_user_msg:
|
||||
prev_user_msg["content"] = "\n".join(
|
||||
[prev_user_msg.get("content", "")] + content_parts
|
||||
)
|
||||
content_parts = []
|
||||
collapsed.append(msg)
|
||||
prev_user_msg = msg if msg.get("role") == "user" else None
|
||||
|
||||
if content_parts and prev_user_msg:
|
||||
prev_user_msg["content"] = "\n".join(
|
||||
[prev_user_msg.get("content", "")] + content_parts
|
||||
)
|
||||
|
||||
return collapsed
|
||||
return transformed
|
||||
|
||||
def transform_response(
|
||||
self,
|
||||
|
||||
@@ -1732,6 +1732,52 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
||||
else:
|
||||
return "stop"
|
||||
|
||||
@staticmethod
|
||||
def _check_prompt_level_content_filter(
|
||||
processed_chunk: GenerateContentResponseBody,
|
||||
response_id: Optional[str],
|
||||
) -> Optional["ModelResponseStream"]:
|
||||
"""
|
||||
Check if prompt is blocked due to content filtering at the prompt level.
|
||||
|
||||
This handles the case where Vertex AI blocks the prompt before generation begins,
|
||||
indicated by promptFeedback.blockReason being present.
|
||||
|
||||
Args:
|
||||
processed_chunk: The parsed response chunk from Vertex AI
|
||||
response_id: The response ID from the chunk
|
||||
|
||||
Returns:
|
||||
ModelResponseStream with content_filter finish_reason if blocked, None otherwise.
|
||||
|
||||
Note:
|
||||
This is consistent with non-streaming _handle_blocked_response() behavior.
|
||||
Candidate-level content filtering (SAFETY, RECITATION, etc.) is handled
|
||||
separately via _process_candidates() → _check_finish_reason().
|
||||
"""
|
||||
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
|
||||
|
||||
# Check if prompt is blocked due to content filtering
|
||||
prompt_feedback = processed_chunk.get("promptFeedback")
|
||||
if prompt_feedback and "blockReason" in prompt_feedback:
|
||||
verbose_logger.debug(
|
||||
f"Prompt blocked due to: {prompt_feedback.get('blockReason')} - {prompt_feedback.get('blockReasonMessage')}"
|
||||
)
|
||||
|
||||
# Create a content_filter response (consistent with non-streaming _handle_blocked_response)
|
||||
choice = StreamingChoices(
|
||||
finish_reason="content_filter",
|
||||
index=0,
|
||||
delta=Delta(content=None, role="assistant"),
|
||||
logprobs=None,
|
||||
enhancements=None,
|
||||
)
|
||||
|
||||
model_response = ModelResponseStream(choices=[choice], id=response_id)
|
||||
return model_response
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _calculate_web_search_requests(grounding_metadata: List[dict]) -> Optional[int]:
|
||||
web_search_requests: Optional[int] = None
|
||||
@@ -2813,6 +2859,15 @@ class ModelResponseIterator:
|
||||
processed_chunk = GenerateContentResponseBody(**chunk) # type: ignore
|
||||
response_id = processed_chunk.get("responseId")
|
||||
model_response = ModelResponseStream(choices=[], id=response_id)
|
||||
|
||||
# Check if prompt is blocked due to content filtering
|
||||
blocked_response = VertexGeminiConfig._check_prompt_level_content_filter(
|
||||
processed_chunk=processed_chunk,
|
||||
response_id=response_id,
|
||||
)
|
||||
if blocked_response is not None:
|
||||
model_response = blocked_response
|
||||
|
||||
usage: Optional[Usage] = None
|
||||
_candidates: Optional[List[Candidates]] = processed_chunk.get("candidates")
|
||||
grounding_metadata: List[dict] = []
|
||||
|
||||
@@ -6,6 +6,7 @@ Unified Guardrail, leveraging LiteLLM's /applyGuardrail endpoint
|
||||
3. Implements a way to call /applyGuardrail endpoint for `/chat/completions` + `/v1/messages` requests on async_post_call_streaming_iterator_hook
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any, AsyncGenerator, List, Optional, Union
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
@@ -349,22 +350,26 @@ class UnifiedLLMGuardrails(CustomLogger):
|
||||
guardrail_to_apply.guardrail_name,
|
||||
)
|
||||
|
||||
# Deep-copy the current chunk before guardrail processing.
|
||||
# process_output_streaming_response modifies responses_so_far
|
||||
# in-place: it puts the combined guardrailed text in the first
|
||||
# chunk and clears all subsequent chunks to "". Without this
|
||||
# copy, yielding processed_items[-1] would yield an empty
|
||||
# string, permanently losing this chunk's content.
|
||||
original_item = copy.deepcopy(item)
|
||||
|
||||
endpoint_translation = endpoint_guardrail_translation_mappings[
|
||||
CallTypes(call_type)
|
||||
]()
|
||||
|
||||
processed_items = (
|
||||
await endpoint_translation.process_output_streaming_response(
|
||||
responses_so_far=responses_so_far,
|
||||
guardrail_to_apply=guardrail_to_apply,
|
||||
litellm_logging_obj=request_data.get("litellm_logging_obj"),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
await endpoint_translation.process_output_streaming_response(
|
||||
responses_so_far=responses_so_far,
|
||||
guardrail_to_apply=guardrail_to_apply,
|
||||
litellm_logging_obj=request_data.get("litellm_logging_obj"),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
last_item = processed_items[-1]
|
||||
|
||||
yield last_item
|
||||
yield original_item
|
||||
else:
|
||||
yield item
|
||||
|
||||
|
||||
Generated
+60
-23
@@ -725,6 +725,18 @@ markers = {main = "(platform_python_implementation != \"PyPy\" or extra == \"pro
|
||||
[package.dependencies]
|
||||
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
|
||||
|
||||
[[package]]
|
||||
name = "chardet"
|
||||
version = "5.2.0"
|
||||
description = "Universal encoding detector for Python 3"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
|
||||
{file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
@@ -1302,6 +1314,27 @@ dev = ["autoflake", "black", "build", "databricks-connect", "httpx", "ipython",
|
||||
notebook = ["ipython (>=8,<10)", "ipywidgets (>=8,<9)"]
|
||||
openai = ["httpx", "langchain-openai ; python_version > \"3.7\"", "openai"]
|
||||
|
||||
[[package]]
|
||||
name = "diff-cover"
|
||||
version = "9.7.2"
|
||||
description = "Run coverage and linting reports on diffs"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "diff_cover-9.7.2-py3-none-any.whl", hash = "sha256:cd6498620c747c2493a6c83c14362c32868bfd91cd8d0dd093f136070ec4ffc5"},
|
||||
{file = "diff_cover-9.7.2.tar.gz", hash = "sha256:872c820d2ecbf79c61d52c7dc70419015e0ab9289589566c791dd270fc0c6e3b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
chardet = ">=3.0.0"
|
||||
Jinja2 = ">=2.7.1"
|
||||
pluggy = ">=0.13.1,<2"
|
||||
Pygments = ">=2.19.1,<3.0.0"
|
||||
|
||||
[package.extras]
|
||||
toml = ["tomli (>=1.2.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "diskcache"
|
||||
version = "5.6.3"
|
||||
@@ -3087,7 +3120,7 @@ version = "3.1.6"
|
||||
description = "A very fast and expressive template engine."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main", "proxy-dev"]
|
||||
groups = ["main", "dev", "proxy-dev"]
|
||||
files = [
|
||||
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
|
||||
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
|
||||
@@ -3517,7 +3550,7 @@ version = "3.0.3"
|
||||
description = "Safely add untrusted strings to HTML/XML markup."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "proxy-dev"]
|
||||
groups = ["main", "dev", "proxy-dev"]
|
||||
files = [
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
|
||||
@@ -5522,14 +5555,14 @@ files = [
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"utils\" or extra == \"proxy\""
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||
]
|
||||
markers = {main = "extra == \"utils\" or extra == \"proxy\""}
|
||||
|
||||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
@@ -6608,29 +6641,29 @@ pyasn1 = ">=0.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.1.15"
|
||||
version = "0.2.2"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"},
|
||||
{file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"},
|
||||
{file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"},
|
||||
{file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"},
|
||||
{file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"},
|
||||
{file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"},
|
||||
{file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"},
|
||||
{file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"},
|
||||
{file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"},
|
||||
{file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"},
|
||||
{file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"},
|
||||
{file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"},
|
||||
{file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"},
|
||||
{file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"},
|
||||
{file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"},
|
||||
{file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"},
|
||||
{file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"},
|
||||
{file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"},
|
||||
{file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"},
|
||||
{file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"},
|
||||
{file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"},
|
||||
{file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"},
|
||||
{file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8498,4 +8531,8 @@ utils = ["numpydoc"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.9,<4.0"
|
||||
<<<<<<< litellm_oss_staging_02_04_2026
|
||||
content-hash = "797603dcfef0a79781c7d3cba5dfe18f6aea4aa792220f47487ebc7bd04ae2e3"
|
||||
=======
|
||||
content-hash = "e5447e14dd37e324ac07a8fc6286d27e9a0d355ed93ebb24fc11e3f5df12fd3e"
|
||||
>>>>>>> main
|
||||
|
||||
+2
-1
@@ -139,6 +139,7 @@ litellm = 'litellm:run_server'
|
||||
litellm-proxy = 'litellm.proxy.client.cli:cli'
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
diff-cover = "^9.0"
|
||||
flake8 = "^6.1.0"
|
||||
black = "^23.12.0"
|
||||
mypy = "^1.0"
|
||||
@@ -149,7 +150,7 @@ pytest-retry = "^1.6.3"
|
||||
requests-mock = "^1.12.1"
|
||||
responses = "^0.25.7"
|
||||
respx = "^0.22.0"
|
||||
ruff = "^0.1.0"
|
||||
ruff = "^0.2.1"
|
||||
types-requests = "*"
|
||||
types-setuptools = "*"
|
||||
types-redis = "*"
|
||||
|
||||
@@ -122,40 +122,6 @@ class TestGigaChatCollapseUserMessages:
|
||||
|
||||
return GigaChatConfig()
|
||||
|
||||
def test_no_collapse_single_message(self, config):
|
||||
"""Single message should not be changed"""
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
result = config._collapse_user_messages(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "Hello"
|
||||
|
||||
def test_collapse_consecutive_user_messages(self, config):
|
||||
"""Consecutive user messages should be collapsed"""
|
||||
messages = [
|
||||
{"role": "user", "content": "First"},
|
||||
{"role": "user", "content": "Second"},
|
||||
{"role": "user", "content": "Third"},
|
||||
]
|
||||
result = config._collapse_user_messages(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "First" in result[0]["content"]
|
||||
assert "Second" in result[0]["content"]
|
||||
assert "Third" in result[0]["content"]
|
||||
|
||||
def test_no_collapse_with_assistant_between(self, config):
|
||||
"""Messages with assistant between should not be collapsed"""
|
||||
messages = [
|
||||
{"role": "user", "content": "First"},
|
||||
{"role": "assistant", "content": "Response"},
|
||||
{"role": "user", "content": "Second"},
|
||||
]
|
||||
result = config._collapse_user_messages(messages)
|
||||
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
class TestGigaChatToolsTransformation:
|
||||
"""Tests for tools -> functions conversion"""
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
initialize_standard_callback_dynamic_params,
|
||||
)
|
||||
|
||||
|
||||
def test_dynamic_key_extraction_from_metadata():
|
||||
"""
|
||||
Test extraction of langfuse keys from metadata in kwargs.
|
||||
This simulates a Proxy request where keys are passed in metadata.
|
||||
"""
|
||||
kwargs = {
|
||||
"metadata": {
|
||||
"langfuse_public_key": "pk-test",
|
||||
"langfuse_secret_key": "sk-test",
|
||||
"langfuse_host": "https://test.langfuse.com",
|
||||
}
|
||||
}
|
||||
|
||||
params = initialize_standard_callback_dynamic_params(kwargs)
|
||||
|
||||
assert params.get("langfuse_public_key") == "pk-test"
|
||||
assert params.get("langfuse_secret_key") == "sk-test"
|
||||
assert params.get("langfuse_host") == "https://test.langfuse.com"
|
||||
|
||||
|
||||
def test_dynamic_key_extraction_from_litellm_params_metadata():
|
||||
"""
|
||||
Test extraction of langfuse keys from litellm_params.metadata.
|
||||
"""
|
||||
kwargs = {
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"langfuse_public_key": "pk-litellm",
|
||||
"langfuse_secret_key": "sk-litellm",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params = initialize_standard_callback_dynamic_params(kwargs)
|
||||
|
||||
assert params.get("langfuse_public_key") == "pk-litellm"
|
||||
assert params.get("langfuse_secret_key") == "sk-litellm"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_dynamic_key_extraction_from_metadata()
|
||||
test_dynamic_key_extraction_from_litellm_params_metadata()
|
||||
@@ -1,92 +1,119 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
|
||||
from litellm.types.integrations.langfuse_otel import LangfuseOtelConfig
|
||||
from litellm.integrations.opentelemetry import OpenTelemetryConfig
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
|
||||
class TestLangfuseOtelIntegration:
|
||||
|
||||
def test_get_langfuse_otel_config_with_required_env_vars(self):
|
||||
"""Test that config is created correctly with required environment variables."""
|
||||
# Clean environment of any Langfuse-related variables
|
||||
env_vars_to_clean = ['LANGFUSE_HOST', 'OTEL_EXPORTER_OTLP_ENDPOINT', 'OTEL_EXPORTER_OTLP_HEADERS']
|
||||
with patch.dict(os.environ, {
|
||||
'LANGFUSE_PUBLIC_KEY': 'test_public_key',
|
||||
'LANGFUSE_SECRET_KEY': 'test_secret_key'
|
||||
}, clear=False):
|
||||
env_vars_to_clean = [
|
||||
"LANGFUSE_HOST",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
]
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
||||
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
# Remove any existing Langfuse variables
|
||||
for var in env_vars_to_clean:
|
||||
if var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
|
||||
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
||||
|
||||
assert isinstance(config, LangfuseOtelConfig)
|
||||
assert config.protocol == "otlp_http"
|
||||
assert "Authorization=Basic" in config.otlp_auth_headers
|
||||
# Check that environment variables are set correctly (US default)
|
||||
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://us.cloud.langfuse.com/api/public/otel"
|
||||
assert "Authorization=Basic" in os.environ.get("OTEL_EXPORTER_OTLP_HEADERS", "")
|
||||
|
||||
|
||||
assert isinstance(config, OpenTelemetryConfig)
|
||||
assert config.exporter == "otlp_http"
|
||||
assert "Authorization=Basic" in config.headers
|
||||
# Note: We no longer set os.environ explicitly to avoid leakage
|
||||
# assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://us.cloud.langfuse.com/api/public/otel"
|
||||
# assert "Authorization=Basic" in os.environ.get("OTEL_EXPORTER_OTLP_HEADERS", "")
|
||||
|
||||
def test_get_langfuse_otel_config_missing_keys(self):
|
||||
"""Test that ValueError is raised when required keys are missing."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with pytest.raises(ValueError, match="LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be set"):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be set",
|
||||
):
|
||||
LangfuseOtelLogger.get_langfuse_otel_config()
|
||||
|
||||
|
||||
def test_get_langfuse_otel_config_with_eu_host(self):
|
||||
"""Test config with EU host."""
|
||||
with patch.dict(os.environ, {
|
||||
'LANGFUSE_PUBLIC_KEY': 'test_public_key',
|
||||
'LANGFUSE_SECRET_KEY': 'test_secret_key',
|
||||
'LANGFUSE_HOST': 'https://cloud.langfuse.com'
|
||||
}, clear=False):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
||||
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
||||
"LANGFUSE_HOST": "https://cloud.langfuse.com",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
||||
|
||||
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://cloud.langfuse.com/api/public/otel"
|
||||
|
||||
# Endpoint assertion removed as side effect is gone
|
||||
assert isinstance(config, OpenTelemetryConfig)
|
||||
|
||||
def test_get_langfuse_otel_config_with_custom_host(self):
|
||||
"""Test config with custom host."""
|
||||
with patch.dict(os.environ, {
|
||||
'LANGFUSE_PUBLIC_KEY': 'test_public_key',
|
||||
'LANGFUSE_SECRET_KEY': 'test_secret_key',
|
||||
'LANGFUSE_HOST': 'https://my-langfuse.com'
|
||||
}, clear=False):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
||||
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
||||
"LANGFUSE_HOST": "https://my-langfuse.com",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
||||
|
||||
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://my-langfuse.com/api/public/otel"
|
||||
|
||||
# Endpoint assertion removed as side effect is gone
|
||||
assert isinstance(config, OpenTelemetryConfig)
|
||||
|
||||
def test_get_langfuse_otel_config_with_host_no_protocol(self):
|
||||
"""Test config with custom host without protocol."""
|
||||
with patch.dict(os.environ, {
|
||||
'LANGFUSE_PUBLIC_KEY': 'test_public_key',
|
||||
'LANGFUSE_SECRET_KEY': 'test_secret_key',
|
||||
'LANGFUSE_HOST': 'my-langfuse.com'
|
||||
}, clear=False):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
||||
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
||||
"LANGFUSE_HOST": "my-langfuse.com",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
||||
|
||||
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://my-langfuse.com/api/public/otel"
|
||||
|
||||
# Endpoint assertion removed as side effect is gone
|
||||
assert isinstance(config, OpenTelemetryConfig)
|
||||
|
||||
def test_set_langfuse_otel_attributes(self):
|
||||
"""Test that set_langfuse_otel_attributes calls the Arize utils function."""
|
||||
from litellm.integrations.langfuse.langfuse_otel_attributes import (
|
||||
LangfuseLLMObsOTELAttributes,
|
||||
)
|
||||
|
||||
|
||||
mock_span = MagicMock()
|
||||
mock_kwargs = {"test": "kwargs"}
|
||||
mock_response = {"test": "response"}
|
||||
|
||||
with patch('litellm.integrations.arize._utils.set_attributes') as mock_set_attributes:
|
||||
LangfuseOtelLogger.set_langfuse_otel_attributes(mock_span, mock_kwargs, mock_response)
|
||||
|
||||
mock_set_attributes.assert_called_once_with(mock_span, mock_kwargs, mock_response, LangfuseLLMObsOTELAttributes)
|
||||
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.set_attributes"
|
||||
) as mock_set_attributes:
|
||||
LangfuseOtelLogger.set_langfuse_otel_attributes(
|
||||
mock_span, mock_kwargs, mock_response
|
||||
)
|
||||
|
||||
mock_set_attributes.assert_called_once_with(
|
||||
mock_span, mock_kwargs, mock_response, LangfuseLLMObsOTELAttributes
|
||||
)
|
||||
|
||||
def test_set_langfuse_environment_attribute(self):
|
||||
"""Test that Langfuse environment is set correctly when environment variable is present."""
|
||||
@@ -94,15 +121,17 @@ class TestLangfuseOtelIntegration:
|
||||
mock_kwargs = {"test": "kwargs"}
|
||||
test_env = "staging"
|
||||
|
||||
with patch.dict(os.environ, {'LANGFUSE_TRACING_ENVIRONMENT': test_env}):
|
||||
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(mock_span, mock_kwargs, {})
|
||||
|
||||
with patch.dict(os.environ, {"LANGFUSE_TRACING_ENVIRONMENT": test_env}):
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.safe_set_attribute"
|
||||
) as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
||||
mock_span, mock_kwargs, {}
|
||||
)
|
||||
|
||||
# safe_set_attribute(span, key, value) → positional args
|
||||
mock_safe_set_attribute.assert_called_once_with(
|
||||
mock_span,
|
||||
"langfuse.environment",
|
||||
test_env
|
||||
mock_span, "langfuse.environment", test_env
|
||||
)
|
||||
|
||||
def test_extract_langfuse_metadata_basic(self):
|
||||
@@ -119,11 +148,13 @@ class TestLangfuseOtelIntegration:
|
||||
|
||||
# Build a stub module + class on-the-fly
|
||||
stub_module = types.ModuleType("litellm.integrations.langfuse.langfuse")
|
||||
|
||||
class StubLFLogger:
|
||||
@staticmethod
|
||||
def add_metadata_from_header(litellm_params, metadata):
|
||||
# Echo back existing metadata plus a marker
|
||||
return {**metadata, "enriched": True}
|
||||
|
||||
stub_module.LangFuseLogger = StubLFLogger # type: ignore
|
||||
|
||||
# Register stub in sys.modules so import inside method succeeds
|
||||
@@ -159,11 +190,16 @@ class TestLangfuseOtelIntegration:
|
||||
kwargs = {"litellm_params": {"metadata": metadata}}
|
||||
|
||||
# Capture calls to safe_set_attribute
|
||||
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(MagicMock(), kwargs, None)
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.safe_set_attribute"
|
||||
) as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
||||
MagicMock(), kwargs, None
|
||||
)
|
||||
|
||||
# Build expected calls manually for clarity
|
||||
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
||||
|
||||
expected = {
|
||||
LangfuseSpanAttributes.GENERATION_NAME.value: "gen-name",
|
||||
LangfuseSpanAttributes.GENERATION_ID.value: "gen-id",
|
||||
@@ -176,12 +212,14 @@ class TestLangfuseOtelIntegration:
|
||||
# Lists / dicts should be JSON strings
|
||||
LangfuseSpanAttributes.TAGS.value: json.dumps(["tagA", "tagB"]),
|
||||
LangfuseSpanAttributes.TRACE_NAME.value: "trace-name",
|
||||
LangfuseSpanAttributes.TRACE_ID.value: "trace-id",
|
||||
LangfuseSpanAttributes.TRACE_ID.value: "traceid", # stripped dashes
|
||||
LangfuseSpanAttributes.TRACE_METADATA.value: json.dumps({"k": "v"}),
|
||||
LangfuseSpanAttributes.TRACE_VERSION.value: "t-ver",
|
||||
LangfuseSpanAttributes.TRACE_RELEASE.value: "rel-1",
|
||||
LangfuseSpanAttributes.EXISTING_TRACE_ID.value: "existing-id",
|
||||
LangfuseSpanAttributes.UPDATE_TRACE_KEYS.value: json.dumps(["key1", "key2"]),
|
||||
LangfuseSpanAttributes.UPDATE_TRACE_KEYS.value: json.dumps(
|
||||
["key1", "key2"]
|
||||
),
|
||||
LangfuseSpanAttributes.DEBUG_LANGFUSE.value: True,
|
||||
}
|
||||
|
||||
@@ -191,7 +229,9 @@ class TestLangfuseOtelIntegration:
|
||||
for call in mock_safe_set_attribute.call_args_list
|
||||
}
|
||||
|
||||
assert actual == expected, "Mismatch between expected and actual OTEL attribute mapping."
|
||||
assert (
|
||||
actual == expected
|
||||
), "Mismatch between expected and actual OTEL attribute mapping."
|
||||
|
||||
def test_set_langfuse_specific_attributes_with_content(self):
|
||||
"""Test that _set_langfuse_specific_attributes correctly sets observation.output with regular content response."""
|
||||
@@ -200,15 +240,15 @@ class TestLangfuseOtelIntegration:
|
||||
|
||||
# Create response with content
|
||||
response_obj = ModelResponse(
|
||||
id='chatcmpl-test',
|
||||
model='gpt-4o',
|
||||
id="chatcmpl-test",
|
||||
model="gpt-4o",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason='stop',
|
||||
finish_reason="stop",
|
||||
message={
|
||||
"role": "assistant",
|
||||
"content": "The weather in Tokyo is sunny."
|
||||
}
|
||||
"content": "The weather in Tokyo is sunny.",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -217,20 +257,21 @@ class TestLangfuseOtelIntegration:
|
||||
"messages": [{"role": "user", "content": "What's the weather in Tokyo?"}],
|
||||
}
|
||||
|
||||
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(MagicMock(), kwargs, response_obj)
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.safe_set_attribute"
|
||||
) as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
||||
MagicMock(), kwargs, response_obj
|
||||
)
|
||||
|
||||
expect_output = {
|
||||
LangfuseSpanAttributes.OBSERVATION_INPUT.value: [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's the weather in Tokyo?"
|
||||
}
|
||||
{"role": "user", "content": "What's the weather in Tokyo?"}
|
||||
],
|
||||
LangfuseSpanAttributes.OBSERVATION_OUTPUT.value: {
|
||||
"role": "assistant",
|
||||
"content": "The weather in Tokyo is sunny."
|
||||
}
|
||||
"content": "The weather in Tokyo is sunny.",
|
||||
},
|
||||
}
|
||||
|
||||
# Flatten the actual calls into {key: value}
|
||||
@@ -239,8 +280,9 @@ class TestLangfuseOtelIntegration:
|
||||
for call in mock_safe_set_attribute.call_args_list
|
||||
}
|
||||
|
||||
assert actual == expect_output, "Mismatch in observation input/output OTEL attributes."
|
||||
|
||||
assert (
|
||||
actual == expect_output
|
||||
), "Mismatch in observation input/output OTEL attributes."
|
||||
|
||||
def test_set_langfuse_specific_attributes_with_tool_calls(self):
|
||||
"""Test that _set_langfuse_specific_attributes correctly sets observation.output with tool calls in Langfuse format."""
|
||||
@@ -254,42 +296,44 @@ class TestLangfuseOtelIntegration:
|
||||
|
||||
# Create response with tool calls
|
||||
response_obj = ModelResponse(
|
||||
id='chatcmpl-test',
|
||||
model='gpt-4o',
|
||||
id="chatcmpl-test",
|
||||
model="gpt-4o",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason='tool_calls',
|
||||
finish_reason="tool_calls",
|
||||
message={
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
ChatCompletionMessageToolCall(
|
||||
function=Function(
|
||||
arguments='{"location":"Tokyo"}',
|
||||
name='get_weather'
|
||||
arguments='{"location":"Tokyo"}', name="get_weather"
|
||||
),
|
||||
id='call_123',
|
||||
type='function'
|
||||
id="call_123",
|
||||
type="function",
|
||||
)
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(MagicMock(), {},
|
||||
response_obj)
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.safe_set_attribute"
|
||||
) as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
||||
MagicMock(), {}, response_obj
|
||||
)
|
||||
|
||||
expected = {
|
||||
LangfuseSpanAttributes.OBSERVATION_OUTPUT.value: [
|
||||
{
|
||||
"id": "chatcmpl-test",
|
||||
"name": "get_weather",
|
||||
"arguments": {"location": "Tokyo"},
|
||||
"call_id": "call_123",
|
||||
"type": "function_call"
|
||||
}
|
||||
{
|
||||
"id": "chatcmpl-test",
|
||||
"name": "get_weather",
|
||||
"arguments": {"location": "Tokyo"},
|
||||
"call_id": "call_123",
|
||||
"type": "function_call",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -298,8 +342,9 @@ class TestLangfuseOtelIntegration:
|
||||
call.args[1]: json.loads(call.args[2])
|
||||
for call in mock_safe_set_attribute.call_args_list
|
||||
}
|
||||
assert actual == expected, "Mismatch in observation output OTEL attribute for tool calls."
|
||||
|
||||
assert (
|
||||
actual == expected
|
||||
), "Mismatch in observation output OTEL attribute for tool calls."
|
||||
|
||||
def test_construct_dynamic_otel_headers_with_langfuse_keys(self):
|
||||
"""Test that construct_dynamic_otel_headers creates proper auth headers when langfuse keys are provided."""
|
||||
@@ -307,28 +352,27 @@ class TestLangfuseOtelIntegration:
|
||||
|
||||
# Create dynamic params with langfuse keys
|
||||
dynamic_params = StandardCallbackDynamicParams(
|
||||
langfuse_public_key="test_public_key",
|
||||
langfuse_secret_key="test_secret_key"
|
||||
langfuse_public_key="test_public_key", langfuse_secret_key="test_secret_key"
|
||||
)
|
||||
|
||||
|
||||
logger = LangfuseOtelLogger()
|
||||
result = logger.construct_dynamic_otel_headers(dynamic_params)
|
||||
|
||||
|
||||
# Should return a dict with otlp_auth_headers
|
||||
assert result is not None
|
||||
assert "Authorization" in result
|
||||
|
||||
|
||||
# The auth header should contain the basic auth format
|
||||
auth_header = result["Authorization"]
|
||||
assert auth_header.startswith("Basic ")
|
||||
|
||||
|
||||
# Verify the header format by decoding
|
||||
import base64
|
||||
|
||||
# Extract the base64 part from "Authorization=Basic <base64>"
|
||||
base64_part = auth_header.replace("Basic ", "")
|
||||
decoded = base64.b64decode(base64_part).decode()
|
||||
|
||||
|
||||
assert decoded == "test_public_key:test_secret_key"
|
||||
|
||||
def test_construct_dynamic_otel_headers_empty_params(self):
|
||||
@@ -337,24 +381,28 @@ class TestLangfuseOtelIntegration:
|
||||
|
||||
# Create dynamic params without langfuse keys
|
||||
dynamic_params = StandardCallbackDynamicParams()
|
||||
|
||||
|
||||
logger = LangfuseOtelLogger()
|
||||
result = logger.construct_dynamic_otel_headers(dynamic_params)
|
||||
|
||||
|
||||
# Should return an empty dict
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_get_langfuse_otel_config_with_otel_host_priority(self):
|
||||
"""LANGFUSE_OTEL_HOST should take priority over LANGFUSE_HOST."""
|
||||
with patch.dict(os.environ, {
|
||||
'LANGFUSE_PUBLIC_KEY': 'test_public_key',
|
||||
'LANGFUSE_SECRET_KEY': 'test_secret_key',
|
||||
'LANGFUSE_HOST': 'https://should-not-be-used.com',
|
||||
'LANGFUSE_OTEL_HOST': 'https://otel-host.com'
|
||||
}, clear=False):
|
||||
_ = LangfuseOtelLogger.get_langfuse_otel_config()
|
||||
|
||||
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://otel-host.com/api/public/otel"
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
||||
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
||||
"LANGFUSE_HOST": "https://should-not-be-used.com",
|
||||
"LANGFUSE_OTEL_HOST": "https://otel-host.com",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
||||
assert isinstance(config, OpenTelemetryConfig)
|
||||
# Endpoint assertion removed as side effect is gone
|
||||
|
||||
|
||||
class TestLangfuseOtelResponsesAPI:
|
||||
@@ -369,46 +417,52 @@ class TestLangfuseOtelResponsesAPI:
|
||||
output=[
|
||||
{
|
||||
"type": "message",
|
||||
"content": [{"type": "text", "text": "Hello from responses API"}]
|
||||
"content": [{"type": "text", "text": "Hello from responses API"}],
|
||||
}
|
||||
],
|
||||
parallel_tool_calls=False,
|
||||
tool_choice="auto",
|
||||
tools=[],
|
||||
top_p=1.0
|
||||
top_p=1.0,
|
||||
)
|
||||
|
||||
|
||||
# Create kwargs with metadata that should be logged
|
||||
test_metadata = {
|
||||
"user_id": "test123",
|
||||
"session_id": "abc456",
|
||||
"user_id": "test123",
|
||||
"session_id": "abc456",
|
||||
"custom_field": "test_value",
|
||||
"generation_name": "responses_test_generation",
|
||||
"trace_name": "responses_api_trace"
|
||||
"trace_name": "responses_api_trace",
|
||||
}
|
||||
|
||||
|
||||
kwargs = {
|
||||
"call_type": "responses",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"model": "gpt-4o",
|
||||
"optional_params": {},
|
||||
"litellm_params": {"metadata": test_metadata}
|
||||
"litellm_params": {"metadata": test_metadata},
|
||||
}
|
||||
|
||||
|
||||
mock_span = MagicMock()
|
||||
|
||||
|
||||
from litellm.integrations.langfuse.langfuse_otel_attributes import (
|
||||
LangfuseLLMObsOTELAttributes,
|
||||
)
|
||||
|
||||
with patch('litellm.integrations.arize._utils.set_attributes') as mock_set_attributes:
|
||||
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
|
||||
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.set_attributes"
|
||||
) as mock_set_attributes:
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.safe_set_attribute"
|
||||
) as mock_safe_set_attribute:
|
||||
logger = LangfuseOtelLogger()
|
||||
logger.set_langfuse_otel_attributes(mock_span, kwargs, mock_response)
|
||||
|
||||
|
||||
# Verify that set_attributes was called for general attributes
|
||||
mock_set_attributes.assert_called_once_with(mock_span, kwargs, mock_response, LangfuseLLMObsOTELAttributes)
|
||||
|
||||
mock_set_attributes.assert_called_once_with(
|
||||
mock_span, kwargs, mock_response, LangfuseLLMObsOTELAttributes
|
||||
)
|
||||
|
||||
# Verify that Langfuse-specific attributes were set
|
||||
mock_safe_set_attribute.assert_any_call(
|
||||
mock_span, "langfuse.generation.name", "responses_test_generation"
|
||||
@@ -421,29 +475,30 @@ class TestLangfuseOtelResponsesAPI:
|
||||
"""Test that metadata is correctly extracted from ResponsesAPI kwargs."""
|
||||
# Clean up any existing module mocks
|
||||
import sys
|
||||
|
||||
if "litellm.integrations.langfuse.langfuse" in sys.modules:
|
||||
original_module = sys.modules["litellm.integrations.langfuse.langfuse"]
|
||||
|
||||
sys.modules["litellm.integrations.langfuse.langfuse"]
|
||||
|
||||
test_metadata = {
|
||||
"user_id": "responses_user_123",
|
||||
"session_id": "responses_session_456",
|
||||
"session_id": "responses_session_456",
|
||||
"custom_metadata": {"key": "value"},
|
||||
"generation_name": "responses_generation",
|
||||
"trace_id": "custom_trace_id"
|
||||
"trace_id": "custom_trace_id",
|
||||
}
|
||||
|
||||
|
||||
kwargs = {
|
||||
"call_type": "responses",
|
||||
"model": "gpt-4o",
|
||||
"litellm_params": {"metadata": test_metadata}
|
||||
"litellm_params": {"metadata": test_metadata},
|
||||
}
|
||||
|
||||
|
||||
extracted_metadata = LangfuseOtelLogger._extract_langfuse_metadata(kwargs)
|
||||
|
||||
|
||||
# Verify all expected metadata was extracted (may have additional fields from header enrichment)
|
||||
for key, value in test_metadata.items():
|
||||
assert extracted_metadata[key] == value
|
||||
|
||||
|
||||
assert extracted_metadata["user_id"] == "responses_user_123"
|
||||
assert extracted_metadata["generation_name"] == "responses_generation"
|
||||
assert extracted_metadata["trace_id"] == "custom_trace_id"
|
||||
@@ -457,39 +512,61 @@ class TestLangfuseOtelResponsesAPI:
|
||||
"trace_user_id": "resp_user_456",
|
||||
"session_id": "resp_session_789",
|
||||
"tags": ["responses", "api", "test"],
|
||||
"trace_metadata": {"source": "responses_api", "version": "1.0"}
|
||||
"trace_metadata": {"source": "responses_api", "version": "1.0"},
|
||||
}
|
||||
|
||||
kwargs = {
|
||||
"call_type": "responses",
|
||||
"litellm_params": {"metadata": metadata}
|
||||
}
|
||||
|
||||
|
||||
kwargs = {"call_type": "responses", "litellm_params": {"metadata": metadata}}
|
||||
|
||||
mock_span = MagicMock()
|
||||
|
||||
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
|
||||
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.safe_set_attribute"
|
||||
) as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(mock_span, kwargs, {})
|
||||
|
||||
|
||||
# Verify specific attributes were set
|
||||
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
||||
|
||||
|
||||
expected_calls = [
|
||||
(mock_span, LangfuseSpanAttributes.GENERATION_NAME.value, "responses_gen"),
|
||||
(
|
||||
mock_span,
|
||||
LangfuseSpanAttributes.GENERATION_NAME.value,
|
||||
"responses_gen",
|
||||
),
|
||||
(mock_span, LangfuseSpanAttributes.GENERATION_ID.value, "resp_gen_123"),
|
||||
(mock_span, LangfuseSpanAttributes.TRACE_NAME.value, "responses_trace"),
|
||||
(mock_span, LangfuseSpanAttributes.TRACE_USER_ID.value, "resp_user_456"),
|
||||
(mock_span, LangfuseSpanAttributes.SESSION_ID.value, "resp_session_789"),
|
||||
(mock_span, LangfuseSpanAttributes.TAGS.value, json.dumps(["responses", "api", "test"])),
|
||||
(mock_span, LangfuseSpanAttributes.TRACE_METADATA.value,
|
||||
json.dumps({"source": "responses_api", "version": "1.0"}))
|
||||
(
|
||||
mock_span,
|
||||
LangfuseSpanAttributes.TRACE_USER_ID.value,
|
||||
"resp_user_456",
|
||||
),
|
||||
(
|
||||
mock_span,
|
||||
LangfuseSpanAttributes.SESSION_ID.value,
|
||||
"resp_session_789",
|
||||
),
|
||||
(
|
||||
mock_span,
|
||||
LangfuseSpanAttributes.TAGS.value,
|
||||
json.dumps(["responses", "api", "test"]),
|
||||
),
|
||||
(
|
||||
mock_span,
|
||||
LangfuseSpanAttributes.TRACE_METADATA.value,
|
||||
json.dumps({"source": "responses_api", "version": "1.0"}),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
for expected_call in expected_calls:
|
||||
mock_safe_set_attribute.assert_any_call(*expected_call)
|
||||
|
||||
def test_responses_api_with_output(self):
|
||||
"""Test Langfuse OTEL logger with Responses API output (reasoning + message)."""
|
||||
from openai.types.responses import ResponseReasoningItem, ResponseOutputMessage, ResponseOutputText
|
||||
from openai.types.responses import (
|
||||
ResponseReasoningItem,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
)
|
||||
from openai.types.responses.response_reasoning_item import Summary
|
||||
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
||||
|
||||
@@ -504,9 +581,9 @@ class TestLangfuseOtelResponsesAPI:
|
||||
summary=[
|
||||
Summary(
|
||||
text="Let me analyze this problem step by step...",
|
||||
type="summary_text"
|
||||
type="summary_text",
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
ResponseOutputMessage(
|
||||
id="msg-001",
|
||||
@@ -519,26 +596,33 @@ class TestLangfuseOtelResponsesAPI:
|
||||
text="The weather in San Francisco is sunny, 20°C.",
|
||||
type="output_text",
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"call_type": "responses",
|
||||
"messages": [{"role": "user", "content": "What's the weather in San Francisco?"}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "What's the weather in San Francisco?"}
|
||||
],
|
||||
"model": "gpt-4o",
|
||||
"optional_params": {},
|
||||
}
|
||||
|
||||
mock_span = MagicMock()
|
||||
|
||||
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(mock_span, kwargs, response_obj)
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.safe_set_attribute"
|
||||
) as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
||||
mock_span, kwargs, response_obj
|
||||
)
|
||||
|
||||
# Verify observation output was set
|
||||
output_calls = [
|
||||
call for call in mock_safe_set_attribute.call_args_list
|
||||
call
|
||||
for call in mock_safe_set_attribute.call_args_list
|
||||
if call.args[1] == LangfuseSpanAttributes.OBSERVATION_OUTPUT.value
|
||||
]
|
||||
|
||||
@@ -552,11 +636,17 @@ class TestLangfuseOtelResponsesAPI:
|
||||
|
||||
# Verify reasoning summary
|
||||
assert output_data[0]["role"] == "reasoning_summary"
|
||||
assert output_data[0]["content"] == "Let me analyze this problem step by step..."
|
||||
assert (
|
||||
output_data[0]["content"]
|
||||
== "Let me analyze this problem step by step..."
|
||||
)
|
||||
|
||||
# Verify message
|
||||
assert output_data[1]["role"] == "assistant"
|
||||
assert output_data[1]["content"] == "The weather in San Francisco is sunny, 20°C."
|
||||
assert (
|
||||
output_data[1]["content"]
|
||||
== "The weather in San Francisco is sunny, 20°C."
|
||||
)
|
||||
|
||||
def test_responses_api_with_function_calls(self):
|
||||
"""Test Langfuse OTEL logger with Responses API function_call output."""
|
||||
@@ -574,26 +664,33 @@ class TestLangfuseOtelResponsesAPI:
|
||||
name="get_weather",
|
||||
call_id="call-abc",
|
||||
arguments='{"location": "San Francisco", "unit": "celsius"}',
|
||||
status="completed"
|
||||
status="completed",
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"call_type": "responses",
|
||||
"messages": [{"role": "user", "content": "What's the weather in San Francisco?"}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "What's the weather in San Francisco?"}
|
||||
],
|
||||
"model": "gpt-4o",
|
||||
"optional_params": {},
|
||||
}
|
||||
|
||||
mock_span = MagicMock()
|
||||
|
||||
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(mock_span, kwargs, response_obj)
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.safe_set_attribute"
|
||||
) as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
||||
mock_span, kwargs, response_obj
|
||||
)
|
||||
|
||||
# Verify observation output was set
|
||||
output_calls = [
|
||||
call for call in mock_safe_set_attribute.call_args_list
|
||||
call
|
||||
for call in mock_safe_set_attribute.call_args_list
|
||||
if call.args[1] == LangfuseSpanAttributes.OBSERVATION_OUTPUT.value
|
||||
]
|
||||
|
||||
@@ -615,4 +712,4 @@ class TestLangfuseOtelResponsesAPI:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
pytest.main([__file__])
|
||||
|
||||
@@ -3218,3 +3218,123 @@ def test_video_metadata_only_for_gemini_3():
|
||||
assert file_part_3 is not None
|
||||
assert "media_resolution" in file_part_3, "Gemini 3 should have media_resolution"
|
||||
assert "video_metadata" in file_part_3, "Gemini 3 should have video_metadata"
|
||||
|
||||
|
||||
|
||||
def test_chunk_parser_handles_prompt_feedback_block():
|
||||
"""Test chunk_parser correctly handles promptFeedback.blockReason"""
|
||||
from unittest.mock import Mock
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
ModelResponseIterator,
|
||||
)
|
||||
|
||||
# Arrange - mock a blocked response
|
||||
blocked_chunk = {
|
||||
"promptFeedback": {
|
||||
"blockReason": "PROHIBITED_CONTENT",
|
||||
"blockReasonMessage": "The prompt is blocked due to prohibited contents"
|
||||
},
|
||||
"responseId": "test_response_id",
|
||||
"modelVersion": "gemini-3-pro-preview"
|
||||
}
|
||||
|
||||
logging_obj = Mock()
|
||||
logging_obj.optional_params = {}
|
||||
|
||||
streaming_obj = ModelResponseIterator(
|
||||
streaming_response=iter([]),
|
||||
sync_stream=True,
|
||||
logging_obj=logging_obj
|
||||
)
|
||||
|
||||
# Act
|
||||
result = streaming_obj.chunk_parser(blocked_chunk)
|
||||
|
||||
# Assert
|
||||
assert result is not None, "Result should not be None"
|
||||
assert len(result.choices) == 1, "Should have exactly one choice"
|
||||
assert result.choices[0].finish_reason == "content_filter", f"finish_reason should be content_filter, got {result.choices[0].finish_reason}"
|
||||
assert result.choices[0].delta.content is None, "content should be None"
|
||||
|
||||
|
||||
def test_chunk_parser_handles_prompt_feedback_safety_block():
|
||||
"""Test chunk_parser handles different blockReason types (SAFETY)"""
|
||||
from unittest.mock import Mock
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
ModelResponseIterator,
|
||||
)
|
||||
|
||||
# Arrange - mock a SAFETY blocked response
|
||||
blocked_chunk = {
|
||||
"promptFeedback": {
|
||||
"blockReason": "SAFETY",
|
||||
"blockReasonMessage": "The prompt is blocked due to safety concerns"
|
||||
},
|
||||
"responseId": "test_safety_response_id",
|
||||
}
|
||||
|
||||
logging_obj = Mock()
|
||||
logging_obj.optional_params = {}
|
||||
|
||||
streaming_obj = ModelResponseIterator(
|
||||
streaming_response=iter([]),
|
||||
sync_stream=True,
|
||||
logging_obj=logging_obj
|
||||
)
|
||||
|
||||
# Act
|
||||
result = streaming_obj.chunk_parser(blocked_chunk)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert len(result.choices) == 1
|
||||
assert result.choices[0].finish_reason == "content_filter"
|
||||
|
||||
|
||||
def test_chunk_parser_handles_prompt_feedback_block_with_usage():
|
||||
"""Test chunk_parser correctly extracts usageMetadata when promptFeedback.blockReason is present"""
|
||||
from unittest.mock import Mock
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
ModelResponseIterator,
|
||||
)
|
||||
|
||||
# Arrange - 模拟一个包含 usageMetadata 的 blocked response
|
||||
blocked_chunk = {
|
||||
"promptFeedback": {
|
||||
"blockReason": "PROHIBITED_CONTENT",
|
||||
"blockReasonMessage": "The prompt is blocked due to prohibited contents"
|
||||
},
|
||||
"responseId": "test_response_id_with_usage",
|
||||
"modelVersion": "gemini-3-pro-preview",
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 8175,
|
||||
"candidatesTokenCount": 0,
|
||||
"totalTokenCount": 8175
|
||||
}
|
||||
}
|
||||
|
||||
logging_obj = Mock()
|
||||
logging_obj.optional_params = {}
|
||||
|
||||
streaming_obj = ModelResponseIterator(
|
||||
streaming_response=iter([]),
|
||||
sync_stream=True,
|
||||
logging_obj=logging_obj
|
||||
)
|
||||
|
||||
# Act
|
||||
result = streaming_obj.chunk_parser(blocked_chunk)
|
||||
|
||||
# Assert - 验证 content_filter 响应和 usage 都被正确处理
|
||||
assert result is not None, "Result should not be None"
|
||||
assert len(result.choices) == 1, "Should have exactly one choice"
|
||||
assert result.choices[0].finish_reason == "content_filter", f"finish_reason should be content_filter, got {result.choices[0].finish_reason}"
|
||||
assert result.choices[0].delta.content is None, "content should be None"
|
||||
|
||||
# 验证 usage 信息被正确提取
|
||||
assert hasattr(result, "usage"), "result should have usage attribute"
|
||||
assert result.usage is not None, "usage should not be None"
|
||||
assert result.usage.prompt_tokens == 8175, f"prompt_tokens should be 8175, got {result.usage.prompt_tokens}"
|
||||
assert result.usage.completion_tokens == 0, f"completion_tokens should be 0, got {result.usage.completion_tokens}"
|
||||
assert result.usage.total_tokens == 8175, f"total_tokens should be 8175, got {result.usage.total_tokens}"
|
||||
|
||||
|
||||
+99
-1
@@ -8,12 +8,13 @@ from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTra
|
||||
from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import (
|
||||
MCPGuardrailTranslationHandler,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail import unified_guardrail as unified_module
|
||||
from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import (
|
||||
UnifiedLLMGuardrails,
|
||||
)
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import CallTypes
|
||||
from litellm.types.utils import CallTypes, Delta, ModelResponseStream, StreamingChoices
|
||||
|
||||
|
||||
class RecordingGuardrail(CustomGuardrail):
|
||||
@@ -131,3 +132,100 @@ class TestUnifiedLLMGuardrails:
|
||||
)
|
||||
|
||||
assert guardrail.event_history == [GuardrailEventHooks.during_call]
|
||||
|
||||
class TestAsyncPostCallStreamingIteratorHook:
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_content_not_lost_on_sampled_chunks(self):
|
||||
"""
|
||||
Verify that every chunk's content is preserved in the output stream.
|
||||
|
||||
The bug: process_output_streaming_response puts the combined
|
||||
guardrailed text in the first chunk and clears all subsequent
|
||||
chunks to "". The hook then yielded processed_items[-1] (the
|
||||
cleared last item), permanently losing every Nth chunk's content.
|
||||
"""
|
||||
|
||||
class _ContentClearingTranslation(BaseTranslation):
|
||||
"""Simulates the real OpenAI handler behavior that triggers the bug."""
|
||||
|
||||
async def process_input_messages(self, data, guardrail_to_apply, litellm_logging_obj=None): # type: ignore[override]
|
||||
return data
|
||||
|
||||
async def process_output_response(self, response, guardrail_to_apply, litellm_logging_obj=None, user_api_key_dict=None): # type: ignore[override]
|
||||
return response
|
||||
|
||||
async def process_output_streaming_response(
|
||||
self,
|
||||
responses_so_far,
|
||||
guardrail_to_apply,
|
||||
litellm_logging_obj=None,
|
||||
user_api_key_dict=None,
|
||||
):
|
||||
# Simulate what the real handler does:
|
||||
# put combined text in first chunk, clear the rest
|
||||
combined = ""
|
||||
for resp in responses_so_far:
|
||||
for choice in resp.choices:
|
||||
if choice.delta and choice.delta.content:
|
||||
combined += choice.delta.content
|
||||
|
||||
first_set = False
|
||||
for resp in responses_so_far:
|
||||
for choice in resp.choices:
|
||||
if not first_set:
|
||||
choice.delta.content = combined
|
||||
first_set = True
|
||||
else:
|
||||
choice.delta.content = ""
|
||||
|
||||
return responses_so_far
|
||||
|
||||
# Override the mapping to use our content-clearing translation
|
||||
unified_module.endpoint_guardrail_translation_mappings = {
|
||||
CallTypes.acompletion: _ContentClearingTranslation,
|
||||
}
|
||||
|
||||
handler = UnifiedLLMGuardrails()
|
||||
guardrail = RecordingGuardrail()
|
||||
|
||||
# Create 10 streaming chunks with distinct content
|
||||
chunks = []
|
||||
for i in range(10):
|
||||
chunk = ModelResponseStream(
|
||||
choices=[StreamingChoices(
|
||||
delta=Delta(content=f"word{i} ", role="assistant"),
|
||||
finish_reason=None,
|
||||
)],
|
||||
)
|
||||
chunks.append(chunk)
|
||||
|
||||
async def mock_stream():
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
request_route="/v1/chat/completions",
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"guardrail_to_apply": guardrail,
|
||||
"model": "gpt-4",
|
||||
}
|
||||
|
||||
# Collect all yielded chunks
|
||||
yielded_contents = []
|
||||
async for item in handler.async_post_call_streaming_iterator_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=mock_stream(),
|
||||
request_data=request_data,
|
||||
):
|
||||
content = item.choices[0].delta.content if item.choices[0].delta else None
|
||||
yielded_contents.append(content)
|
||||
|
||||
# Every chunk should have non-empty content
|
||||
for i, content in enumerate(yielded_contents):
|
||||
assert content is not None and content != "", (
|
||||
f"Chunk {i} lost its content (got {content!r}). "
|
||||
f"Expected non-empty content for every streamed chunk."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user