diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index b8c6e91d0d7..ee67228841a 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -15,6 +15,7 @@ from packaging.version import Version import litellm from litellm._logging import verbose_logger from litellm.constants import MAX_LANGFUSE_INITIALIZED_CLIENTS +from litellm.cost_calculator import get_usage_object from litellm.integrations.langfuse.langfuse_mock_client import ( create_mock_langfuse_client, should_use_langfuse_mock, @@ -32,6 +33,7 @@ from litellm.litellm_core_utils.redact_messages import redact_user_api_key_info from litellm.llms.custom_httpx.http_handler import get_httpx_client from litellm.responses.utils import ResponseAPILoggingUtils from litellm.secret_managers.main import str_to_bool +from litellm.types.decisions import DecisionsResponse, OpenAIDecisionResponse from litellm.types.integrations.langfuse import * from litellm.types.llms.openai import HttpxBinaryResponseContent, ResponseAPIUsage, ResponsesAPIResponse from litellm.types.utils import ( @@ -584,7 +586,9 @@ class LangFuseLogger: | TranscriptionResponse | RerankResponse | HttpxBinaryResponseContent - | ResponsesAPIResponse, + | ResponsesAPIResponse + | DecisionsResponse + | OpenAIDecisionResponse, prompt: dict, level: str, status_message: str | None, @@ -634,6 +638,11 @@ class LangFuseLogger: elif response_obj is not None and isinstance(response_obj, litellm.ResponsesAPIResponse): input = prompt output = self._get_responses_api_content_for_langfuse(response_obj) + elif isinstance(response_obj, (DecisionsResponse, OpenAIDecisionResponse)): + return ( # pyright: ignore[reportUnknownVariableType] # prompt is the caller's untyped dict, as in every branch + prompt, + response_obj.model_dump(mode="json", include={"answers"}), + ) elif ( kwargs.get("call_type") is not None and kwargs.get("call_type") == "_arealtime" @@ -859,10 +868,14 @@ class LangFuseLogger: usage = None usage_details = None if response_obj is not None: - if hasattr(response_obj, "id") and response_obj.get("id", None) is not None: + if isinstance(response_obj, _ResponseWithId) and getattr(response_obj, "id", None) is not None: generation_id = _logging_id(start_time, response_obj) _raw_usage_obj: Final[_UsageObject | None] = getattr(response_obj, "usage", None) - _usage_obj: Final = _chat_usage(_raw_usage_obj) + _usage_obj: Final = ( + get_usage_object(response_obj) + if isinstance(response_obj, (DecisionsResponse, OpenAIDecisionResponse)) + else _chat_usage(_raw_usage_obj) + ) if _usage_obj: # Safely get usage values, defaulting None to 0 for Langfuse compatibility. diff --git a/tests/unit/integrations/langfuse/langfuse_expected_request_body/systemone.json b/tests/unit/integrations/langfuse/langfuse_expected_request_body/systemone.json new file mode 100644 index 00000000000..50e1ea18848 --- /dev/null +++ b/tests/unit/integrations/langfuse/langfuse_expected_request_body/systemone.json @@ -0,0 +1,38 @@ +{ + "name": "litellm-asystemone", + "parent_span_id": null, + "attributes": { + "langfuse.observation.cost_details": { + "total": 1.1886e-05 + }, + "langfuse.observation.input": { + "messages": [ + { + "content": "{\"state\": \"Customer writes: my payout has failed three days in a row.\", \"questions\": {\"urgent\": {\"type\": \"noul\", \"instructions\": \"Is this urgent?\"}}}", + "role": "user" + } + ] + }, + "langfuse.observation.level": "DEFAULT", + "langfuse.observation.metadata.api_base": "https://openrouter.ai/api/alpha/decisions", + "langfuse.observation.model.name": "typesafe/jev-1.13", + "langfuse.observation.model.parameters": {}, + "langfuse.observation.output": { + "answers": { + "urgent": { + "noul": 0.87, + "type": "noul" + } + } + }, + "langfuse.observation.type": "generation", + "langfuse.observation.usage_details": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 283, + "output": 20, + "total": 303 + }, + "langfuse.trace.name": "litellm-asystemone" + } +} diff --git a/tests/unit/integrations/langfuse/test_langfuse.py b/tests/unit/integrations/langfuse/test_langfuse.py index 7a374c1affe..747ebd1cc1f 100644 --- a/tests/unit/integrations/langfuse/test_langfuse.py +++ b/tests/unit/integrations/langfuse/test_langfuse.py @@ -433,6 +433,33 @@ async def test_langfuse_logging_vllm_embedding( assert route.call_count == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize("response_id", [None, "decision-1"]) +async def test_langfuse_logging_systemone( + langfuse_export: tuple[respx.Route, str], respx_mock: respx.MockRouter, response_id: str | None +) -> None: + route, trace_id = langfuse_export + answers: Final = {"urgent": {"type": "noul", "noul": 0.87}} + upstream: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond( + json={ + **({"id": response_id} if response_id is not None else {}), + "model": "typesafe/jev-1.13", + "answers": answers, + "usage": {"input_tokens": 283, "output_tokens": 20, "cost": 1.1886e-05}, + } + ) + await litellm.asystemone( + model="openrouter/typesafe/jev-1.13", + state="Customer writes: my payout has failed three days in a row.", + questions={"urgent": {"type": "noul", "instructions": "Is this urgent?"}}, + api_key="caller-key", + metadata={"trace_id": trace_id}, + ) + await _flush_langfuse() + assert upstream.call_count == 1 + _expected_generation(route, "systemone.json", trace_id) + + @pytest.mark.asyncio async def test_langfuse_logging_with_router(langfuse_export: tuple[respx.Route, str]) -> None: route, trace_id = langfuse_export