diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index d83b026a024..ce1d47944d8 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -174,6 +174,7 @@ def _as_steering_key_sequence(value: object) -> tuple[str, ...]: MINIMUM_LANGFUSE_VERSION: Final = "4.7" UNSUPPORTED_LANGFUSE_VERSION: Final = "5" +PROMPT_CACHE_TTL_ENV: Final = "LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS" def installed_langfuse_version() -> str: @@ -205,6 +206,22 @@ def raise_if_unsupported_langfuse_version(installed_version: str) -> None: ) +def whole_number(raw: str) -> int | None: + try: + return int(raw) + except ValueError: + return None + + +def raise_if_unusable_prompt_cache_ttl() -> None: + """The v4 SDK runs ``int()`` on this variable while it is being imported, so a value that is not a whole + number has to be named here, before that import fails with a bare ``ValueError`` on every request.""" + raw: Final = os.environ.get(PROMPT_CACHE_TTL_ENV) + if raw is None or whole_number(raw) is not None: + return + raise ValueError(f"\033[91m{PROMPT_CACHE_TTL_ENV}={raw!r} must be a whole number of seconds\033[0m") + + def _optional_str(value: object) -> str | None: """v4 sets attribute values raw; a non-string version would be dropped by the server.""" return str(value) if value is not None else None @@ -284,6 +301,7 @@ class LangFuseLogger: f"\033[91mLangfuse not installed, try running 'pip install langfuse' to fix this error: {e}\033[0m" ) from e raise_if_unsupported_langfuse_version(self.langfuse_sdk_version) + raise_if_unusable_prompt_cache_ttl() from litellm.integrations.langfuse.langfuse_sdk import configured_release self.public_key, self.secret_key, self.langfuse_host = resolve_langfuse_credentials( diff --git a/litellm/integrations/langfuse/langfuse_prompt_management.py b/litellm/integrations/langfuse/langfuse_prompt_management.py index 732f3e59832..3786087ba91 100644 --- a/litellm/integrations/langfuse/langfuse_prompt_management.py +++ b/litellm/integrations/langfuse/langfuse_prompt_management.py @@ -23,6 +23,7 @@ from .langfuse import ( LangFuseLogger, installed_langfuse_version, raise_if_unsupported_langfuse_version, + raise_if_unusable_prompt_cache_ttl, resolve_langfuse_credentials, warn_if_upstream_langfuse_configured, ) @@ -70,6 +71,8 @@ def langfuse_client_init( Raises: Exception: If langfuse package is not installed """ + raise_if_unsupported_langfuse_version(installed_langfuse_version()) + raise_if_unusable_prompt_cache_ttl() try: from .langfuse_sdk import build_langfuse_client except Exception as e: @@ -89,7 +92,6 @@ def langfuse_client_init( # add http:// if unset, assume communicating over private network - e.g. render langfuse_host = "http://" + langfuse_host - raise_if_unsupported_langfuse_version(installed_langfuse_version()) warn_if_upstream_langfuse_configured() httpx_client: Final = create_mock_langfuse_client() if should_use_langfuse_mock() else HTTPHandler().client @@ -119,6 +121,7 @@ class LangfusePromptManagement(LangFuseLogger, PromptManagementBase, CustomLogge self.langfuse_sdk_version = installed_langfuse_version() raise_if_unsupported_langfuse_version(self.langfuse_sdk_version) + raise_if_unusable_prompt_cache_ttl() from .langfuse_sdk import acquire_langfuse_tracing, configured_release diff --git a/litellm/integrations/langfuse/langfuse_sdk.py b/litellm/integrations/langfuse/langfuse_sdk.py index f39fdf3898d..477d8ae0ac3 100644 --- a/litellm/integrations/langfuse/langfuse_sdk.py +++ b/litellm/integrations/langfuse/langfuse_sdk.py @@ -32,6 +32,7 @@ from opentelemetry.util.types import Attributes, AttributeValue import litellm from litellm._logging import verbose_logger +from litellm.integrations.langfuse.langfuse import PROMPT_CACHE_TTL_ENV, whole_number from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.llms.custom_httpx.http_handler import HTTPHandler, _get_httpx_client @@ -70,8 +71,7 @@ _CHANNEL_RETIRE_GRACE_SECONDS: Final = 60.0 _DEFAULT_TIMEOUT_SECONDS: Final = 20.0 _DEFAULT_MAX_RETRIES: Final = 3 _DEFAULT_PROMPT_CACHE_TTL_SECONDS: Final = 60.0 -_INT64_MIN: Final = -(2**63) -_INT64_MAX: Final = 2**63 - 1 +_JSON_SAFE_INT: Final = 2**53 - 1 _COMMON_RELEASE_ENVS: Final = ( "RENDER_GIT_COMMIT", "CI_COMMIT_SHA", @@ -162,10 +162,11 @@ def _present(entries: Iterable[tuple[str, AttributeValue | None]]) -> Mapping[st def _metadata_value(value: object) -> AttributeValue | None: - """A metadata value as OTLP can carry it: ints past int64 go as strings, as the v2 serializer sent them.""" + """A metadata value as it survives the trip: OTLP drops ints past int64 and a JSON reader rounds ints past + 2**53, so those go as strings, which is how v2's readback showed them.""" if isinstance(value, (str, bool)): return value - if isinstance(value, int) and _INT64_MIN <= value <= _INT64_MAX: + if isinstance(value, int) and -_JSON_SAFE_INT <= value <= _JSON_SAFE_INT: return value return _serialize(value) @@ -488,19 +489,21 @@ def configured_release() -> str | None: def configured_prompt_cache_ttl() -> float: - """``LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS``, the SDK's knob, with its 60 s default when unset or unusable.""" - raw: Final = os.environ.get("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS") + """``LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS`` in whole seconds as the SDK reads it, its 60 s default when unset + or unusable; ``raise_if_unusable_prompt_cache_ttl`` has already named a value that is not a whole number.""" + raw: Final = os.environ.get(PROMPT_CACHE_TTL_ENV) if raw is None: return _DEFAULT_PROMPT_CACHE_TTL_SECONDS - parsed: Final = _parse_float(raw) + parsed: Final = whole_number(raw) if parsed is None or parsed < 0: verbose_logger.warning( - "LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS=%r is not a number of seconds at or above 0; caching prompts for %.0f s", + "%s=%r is not a whole number of seconds at or above 0; caching prompts for %.0f s", + PROMPT_CACHE_TTL_ENV, raw, _DEFAULT_PROMPT_CACHE_TTL_SECONDS, ) return _DEFAULT_PROMPT_CACHE_TTL_SECONDS - return parsed + return float(parsed) def configured_flush_at() -> int: @@ -538,10 +541,14 @@ class DiscardingSpanExporter(SpanExporter): return True -_RETRYABLE_EXPORT_STATUSES: Final = frozenset({408, 429, 500, 502, 503, 504}) _ExportOutcome = Literal["delivered", "retry", "rejected"] +def _retryable_status(status: int) -> bool: + """Any 5xx, a timeout or a rate limit: what the v2 consumer re-sent, plus the 408 the OTLP exporter retries.""" + return status in (408, 429) or 500 <= status <= 599 + + @dataclass(frozen=True, slots=True) class LangfuseSpanExporter(SpanExporter): """OTLP/HTTP protobuf export through litellm's own HTTP handler. @@ -581,7 +588,7 @@ class LangfuseSpanExporter(SpanExporter): self.handler.post(self.endpoint, data=body, headers=dict(self.headers), timeout=self.timeout) except httpx.HTTPStatusError as error: status: Final = error.response.status_code - if status in _RETRYABLE_EXPORT_STATUSES: + if _retryable_status(status): return "retry" verbose_logger.error("Langfuse rejected an export to %s with HTTP %d", self.endpoint, status) return "rejected" diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py b/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py index af708bf0176..5e05ede0688 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py @@ -238,3 +238,18 @@ def test_old_sdk_fails_with_the_upgrade_message_before_the_otel_module_is_import assert "2.59.7" in str(raised.value) assert "langfuse_otel" in str(raised.value) + + +@pytest.mark.parametrize("raw", ["abc", "2.5"], ids=["text", "fraction"]) +def test_prompt_cache_ttl_typo_is_named_instead_of_reported_as_not_installed(monkeypatch, raw): + """The v4 SDK runs ``int()`` on this variable at import, and ``langfuse_client_init`` wraps any import + failure as "Langfuse not installed", so the gate has to run before that import.""" + monkeypatch.setenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raw) + monkeypatch.setitem(sys.modules, "litellm.integrations.langfuse.langfuse_sdk", None) + langfuse_client_init.cache_clear() + + with pytest.raises(ValueError, match="LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS") as raised: + langfuse_client_init(langfuse_public_key="pk-ttl", langfuse_secret="sk-ttl", langfuse_host="http://127.0.0.1:1") + + assert "not installed" not in str(raised.value) + assert repr(raw) in str(raised.value) diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py index 5bddf9db938..f9525eadfde 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py @@ -527,17 +527,30 @@ def test_host_otel_resource_env_does_not_reach_the_langfuse_resource(monkeypatch @pytest.mark.parametrize( ("value", "encoded"), [ - (2**63 - 1, ("int_value", 2**63 - 1)), + (2**53 - 1, ("int_value", 2**53 - 1)), + (-(2**53) + 1, ("int_value", -(2**53) + 1)), + (2**53, ("string_value", str(2**53))), + (2**63 - 1, ("string_value", str(2**63 - 1))), (2**63, ("string_value", str(2**63))), (10**20, ("string_value", str(10**20))), (-(2**63) - 1, ("string_value", str(-(2**63) - 1))), (True, ("bool_value", True)), ], - ids=["int64-max", "int64-max-plus-one", "huge", "int64-min-minus-one", "bool"], + ids=[ + "json-safe-max", + "json-safe-min", + "json-safe-plus-one", + "int64-max", + "int64-max-plus-one", + "huge", + "int64-min-minus-one", + "bool", + ], ) -def test_metadata_ints_past_int64_reach_the_wire_as_strings(value, encoded): - """OTLP carries int64 only and its encoder silently drops any attribute it cannot fit, while the - export still succeeds; v2's serializer sent such ints as strings, so the value has to survive.""" +def test_metadata_ints_past_the_json_safe_range_reach_the_wire_as_strings(value, encoded): + """OTLP carries int64 only and its encoder silently drops any attribute it cannot fit, while the export + still succeeds, and Langfuse's reader rounds ints past 2**53 (int64 max read back as 9223372036854776000 + on 2026-09-21, where the v2 leg showed the exact digits as a string), so both ranges go as strings.""" from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans exporter = InMemorySpanExporter() @@ -562,18 +575,19 @@ def test_metadata_ints_past_int64_reach_the_wire_as_strings(value, encoded): @pytest.mark.parametrize( ("raw", "expected"), - [(None, 60.0), ("5", 5.0), ("0", 0.0), ("2.5", 2.5), ("-1", 60.0), ("abc", 60.0)], - ids=["unset", "whole", "zero", "fraction", "negative", "text"], + [(None, 60.0), ("5", 5.0), ("0", 0.0), (" -1 ", 60.0), ("2.5", 60.0), ("abc", 60.0)], + ids=["unset", "whole", "zero", "negative", "fraction", "text"], ) def test_prompt_cache_ttl_env_falls_back_instead_of_raising(monkeypatch: pytest.MonkeyPatch, raw, expected, caplog): - """A typo in the SDK's TTL knob used to raise out of logger construction and fail the request.""" + """The SDK reads this knob as whole seconds; a negative one passes its import but must not cache forever, + and anything else falls back rather than raising out of logger construction.""" if raw is None: monkeypatch.delenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raising=False) else: monkeypatch.setenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raw) with caplog.at_level(logging.WARNING, logger="LiteLLM"): assert configured_prompt_cache_ttl() == expected - assert ("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS" in caplog.text) is (raw in ("-1", "abc")) + assert ("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS" in caplog.text) is (expected == 60.0 and raw is not None) def test_many_metadata_keys_never_evict_the_generation_input_and_output(): @@ -1088,11 +1102,11 @@ def test_exporter_posts_the_otlp_batch_through_litellm_http_handler(monkeypatch) @pytest.mark.parametrize( "failure", - [httpx.ReadTimeout("stalled"), httpx.ConnectError("refused"), 503, 429], - ids=["read-timeout", "connect-error", "http-503", "http-429"], + [httpx.ReadTimeout("stalled"), httpx.ConnectError("refused"), 503, 429, 408, 501, 507, 599], + ids=["read-timeout", "connect-error", "http-503", "http-429", "http-408", "http-501", "http-507", "http-599"], ) def test_exporter_retries_a_failed_round_trip_and_then_succeeds(monkeypatch, failure): - """A stalled or restarting destination used to drop the batch outright; v2 backed off and re-sent it.""" + """A stalled or restarting destination used to drop the batch outright; v2 backed off and re-sent every 5xx.""" slept = [] monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", slept.append) exporter, seen = _exporter_over([failure, failure, 200], delays=(0.5, 1.5, 2.5)) @@ -1113,7 +1127,7 @@ def test_exporter_gives_up_after_the_last_delay(monkeypatch): assert slept == [1.0, 2.0] -@pytest.mark.parametrize("status", [400, 401, 403, 404]) +@pytest.mark.parametrize("status", [400, 401, 403, 404, 413, 422, 499]) def test_exporter_does_not_retry_a_rejected_batch(monkeypatch, status): """Bad credentials or a bad payload will not get better on the next attempt, so retrying only delays the flush.""" slept = [] diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index 2c511e71644..63621615355 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -2305,6 +2305,27 @@ def test_missing_sdk_is_reported_as_not_installed(monkeypatch): _build_langfuse_logger(monkeypatch, langfuse_public_key="pk-no-sdk") +@pytest.mark.parametrize("raw", ["abc", "2.5", ""], ids=["text", "fraction", "empty"]) +def test_prompt_cache_ttl_typo_is_named_before_the_sdk_is_imported(monkeypatch, raw): + """The v4 SDK evaluates ``int(LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS)`` at import, so without this + gate every request failed with a bare ``invalid literal for int()`` that never named the variable.""" + import sys + + monkeypatch.setenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raw) + monkeypatch.setitem(sys.modules, "litellm.integrations.langfuse.langfuse_sdk", None) + + with pytest.raises(ValueError, match="LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS") as raised: + _build_langfuse_logger(monkeypatch, langfuse_public_key="pk-ttl-typo") + + assert repr(raw) in str(raised.value) + + +@pytest.mark.parametrize("raw", ["5", " -3 ", "+0"], ids=["whole", "negative", "signed-zero"]) +def test_whole_second_prompt_cache_ttl_passes_the_gate(monkeypatch, raw): + monkeypatch.setenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raw) + langfuse_module.raise_if_unusable_prompt_cache_ttl() + + def test_stopped_logger_hands_its_export_channel_back(monkeypatch): """`DynamicLoggingCache` calls `stop()` on expiry; the channel must be retired once every logger that held it has stopped, or each credential rotation leaks a batch export thread."""