diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index 0efc7d66876..8ad5355de88 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -1,5 +1,6 @@ #### What this does #### # On success, logs events to Langfuse +import inspect import os import traceback from datetime import datetime @@ -113,6 +114,26 @@ def resolve_langfuse_credentials( return public_key, secret_key, resolved_host +def _langfuse_init_supports_sdk_integration() -> bool: + """ + Whether the installed Langfuse SDK's ``Langfuse.__init__`` accepts the + ``sdk_integration`` kwarg. + + The kwarg was added in Langfuse 2.6.0 and removed in Langfuse 3.x. We + detect by signature introspection rather than version-string comparison + so the gate stays correct across past and future SDK releases. + + See: https://github.com/BerriAI/litellm/issues/13137 + """ + from langfuse import Langfuse + + try: + params = inspect.signature(Langfuse.__init__).parameters + except (TypeError, ValueError): + return False + return "sdk_integration" in params + + class LangFuseLogger: # Class variables or attributes def __init__( @@ -169,7 +190,7 @@ class LangFuseLogger: } self.langfuse_sdk_version: str = langfuse.version.__version__ - if Version(self.langfuse_sdk_version) >= Version("2.6.0"): + if _langfuse_init_supports_sdk_integration(): parameters["sdk_integration"] = "litellm" self.Langfuse: Langfuse = self.safe_init_langfuse_client(parameters) diff --git a/litellm/integrations/langfuse/langfuse_prompt_management.py b/litellm/integrations/langfuse/langfuse_prompt_management.py index b7a565512c6..96c3db3f124 100644 --- a/litellm/integrations/langfuse/langfuse_prompt_management.py +++ b/litellm/integrations/langfuse/langfuse_prompt_management.py @@ -6,7 +6,6 @@ import os from functools import lru_cache from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast -from packaging.version import Version from typing_extensions import TypeAlias from litellm.integrations.custom_logger import CustomLogger @@ -20,7 +19,11 @@ from ...litellm_core_utils.specialty_caches.dynamic_logging_cache import ( DynamicLoggingCache, ) from ..prompt_management_base import PromptManagementBase -from .langfuse import LangFuseLogger, resolve_langfuse_credentials +from .langfuse import ( + LangFuseLogger, + _langfuse_init_supports_sdk_integration, + resolve_langfuse_credentials, +) from .langfuse_handler import LangFuseHandler if TYPE_CHECKING: @@ -64,7 +67,6 @@ def langfuse_client_init( Exception: If langfuse package is not installed """ try: - import langfuse from langfuse import Langfuse except Exception as e: raise Exception( @@ -99,7 +101,7 @@ def langfuse_client_init( ), # flush interval in seconds } - if Version(langfuse.version.__version__) >= Version("2.6.0"): + if _langfuse_init_supports_sdk_integration(): parameters["sdk_integration"] = "litellm" client = Langfuse(**parameters) diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index 98e5d1f6dd4..a70eeae707e 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -19,6 +19,143 @@ from litellm.integrations.langfuse.langfuse import LangFuseLogger from litellm.types.integrations.langfuse import * +def _make_fake_langfuse_module(langfuse_class): + """Build a stand-in for the langfuse package — only what LangFuseLogger / langfuse_client_init touch at init time.""" + module = MagicMock() + module.Langfuse = langfuse_class + module.version = MagicMock() + module.version.__version__ = "3.14.0" + return module + + +class _FakeLangfuseV3: + """Mimics Langfuse SDK v3.x: no ``sdk_integration`` kwarg, no ``**kwargs`` catchall.""" + + def __init__( + self, + public_key=None, + secret_key=None, + host=None, + release=None, + debug=None, + flush_interval=None, + httpx_client=None, + environment=None, + ): + self.public_key = public_key + self.client = MagicMock() + self.client.projects.get.side_effect = Exception("offline test") + + +class _FakeLangfuseV2: + """Mimics Langfuse SDK v2.6+: ``sdk_integration`` is in the signature.""" + + def __init__( + self, + public_key=None, + secret_key=None, + host=None, + release=None, + debug=None, + flush_interval=None, + httpx_client=None, + sdk_integration=None, + ): + self.public_key = public_key + self.sdk_integration = sdk_integration + self.client = MagicMock() + self.client.projects.get.side_effect = Exception("offline test") + + +def test_langfuse_init_supports_sdk_integration_true_on_v2_signature(): + """Helper returns True when ``Langfuse.__init__`` accepts ``sdk_integration`` (v2.6+).""" + fake_module = _make_fake_langfuse_module(_FakeLangfuseV2) + with patch.dict(sys.modules, {"langfuse": fake_module}): + from litellm.integrations.langfuse.langfuse import ( + _langfuse_init_supports_sdk_integration, + ) + + assert _langfuse_init_supports_sdk_integration() is True + + +def test_langfuse_init_supports_sdk_integration_false_on_v3_signature(): + """Helper returns False when ``Langfuse.__init__`` lacks ``sdk_integration`` (v3+). + + Regression for #13137 / #11703 — passing the kwarg to v3 raises TypeError. + """ + fake_module = _make_fake_langfuse_module(_FakeLangfuseV3) + with patch.dict(sys.modules, {"langfuse": fake_module}): + from litellm.integrations.langfuse.langfuse import ( + _langfuse_init_supports_sdk_integration, + ) + + assert _langfuse_init_supports_sdk_integration() is False + + +def test_lang_fuse_logger_does_not_pass_sdk_integration_on_v3(): + """``LangFuseLogger`` constructs successfully against a v3-style ``Langfuse.__init__`` (no TypeError). + + This is the customer-reported regression: with ``langfuse>=3.0.0`` installed, + LangFuseLogger init blew up because the kwarg was unconditionally injected. + """ + fake_module = _make_fake_langfuse_module(_FakeLangfuseV3) + with patch.dict(sys.modules, {"langfuse": fake_module}): + from litellm.integrations.langfuse.langfuse import LangFuseLogger + + logger = LangFuseLogger( + langfuse_public_key="pk-test", + langfuse_secret="sk-test", + langfuse_host="https://example.com", + ) + + assert isinstance(logger.Langfuse, _FakeLangfuseV3) + assert logger.Langfuse.public_key == "pk-test" + + +def test_lang_fuse_logger_passes_sdk_integration_on_v2(): + """``LangFuseLogger`` still tags ``sdk_integration="litellm"`` on a v2-style SDK. + + Guards against silently dropping the litellm-origin tag for users on v2.6+. + """ + fake_module = _make_fake_langfuse_module(_FakeLangfuseV2) + fake_module.version.__version__ = "2.60.0" + with patch.dict(sys.modules, {"langfuse": fake_module}): + from litellm.integrations.langfuse.langfuse import LangFuseLogger + + logger = LangFuseLogger( + langfuse_public_key="pk-test", + langfuse_secret="sk-test", + langfuse_host="https://example.com", + ) + + assert isinstance(logger.Langfuse, _FakeLangfuseV2) + assert logger.Langfuse.sdk_integration == "litellm" + + +def test_langfuse_client_init_does_not_pass_sdk_integration_on_v3(): + """``langfuse_client_init`` (prompt management path) constructs against a v3-style SDK without TypeError. + + Mirrors the LangFuseLogger test for the second crash site. + """ + fake_module = _make_fake_langfuse_module(_FakeLangfuseV3) + with patch.dict(sys.modules, {"langfuse": fake_module}): + from litellm.integrations.langfuse.langfuse_prompt_management import ( + langfuse_client_init, + ) + + # lru_cache on langfuse_client_init means the first call to a previously-cached + # arg tuple wins — clear to make this test independent of order. + langfuse_client_init.cache_clear() + client = langfuse_client_init( + langfuse_public_key="pk-test", + langfuse_secret="sk-test", + langfuse_host="https://example.com", + ) + + assert isinstance(client, _FakeLangfuseV3) + assert client.public_key == "pk-test" + + class TestLangfuseUsageDetails(unittest.TestCase): def setUp(self): # Save global Langfuse client counter to restore after test