From 19cc85489e0846b0b25b34671059041fc8f52efb Mon Sep 17 00:00:00 2001 From: LeChristopher Blackwell Date: Tue, 7 Jul 2026 15:03:54 -0700 Subject: [PATCH] fix(openai): handle null choices in default response extraction An OpenAI-compatible provider can return a chat/completion response with `choices: null`. `response.get("choices", [])` returns None in that case (the default only applies to a missing key, not an explicit null), so the following `len(choices)` raised `TypeError: object of type 'NoneType' has no len()` and masked the provider's real response. Fall back to [] on a null value so the wrapper treats it as no choices. Also guard the httpx logger handler so it is only added when none exists, matching the comment's stated intent and avoiding duplicate log lines. Fixes #1741 --- langfuse/logger.py | 11 +++++++---- langfuse/openai.py | 4 ++-- tests/unit/test_logger.py | 17 +++++++++++++++++ tests/unit/test_openai.py | 12 ++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/langfuse/logger.py b/langfuse/logger.py index afe8c0aef..a18fb0e1c 100644 --- a/langfuse/logger.py +++ b/langfuse/logger.py @@ -22,7 +22,10 @@ httpx_logger.setLevel(logging.WARNING) # Add console handler if no handlers exist -console_handler = logging.StreamHandler() -formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") -console_handler.setFormatter(formatter) -httpx_logger.addHandler(console_handler) +if not httpx_logger.handlers: + console_handler = logging.StreamHandler() + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + console_handler.setFormatter(formatter) + httpx_logger.addHandler(console_handler) diff --git a/langfuse/openai.py b/langfuse/openai.py index 97bb0f89e..e2a337117 100644 --- a/langfuse/openai.py +++ b/langfuse/openai.py @@ -898,7 +898,7 @@ def _get_langfuse_data_from_default_response( completion = None if resource.type == "completion": - choices = response.get("choices", []) + choices = response.get("choices") or [] if len(choices) > 0: choice = choices[-1] @@ -908,7 +908,7 @@ def _get_langfuse_data_from_default_response( completion = _extract_response_api_completion(response.get("output", {})) elif resource.type == "chat": - choices = response.get("choices", []) + choices = response.get("choices") or [] if len(choices) > 0: # If multiple choices were generated, we'll show all of them in the UI as a list. if len(choices) > 1: diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index 6417fba28..09dcca72b 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -1,3 +1,5 @@ +import importlib +import logging import os from langfuse import Langfuse @@ -34,3 +36,18 @@ def test_debug_langfuse(): # Reset langfuse_logger.setLevel("WARNING") + + +def test_httpx_handler_not_duplicated_when_handler_exists(): + import langfuse.logger as lf_logger + + httpx_logger = logging.getLogger("httpx") + saved = httpx_logger.handlers[:] + httpx_logger.handlers = [] + pre_existing = logging.NullHandler() + httpx_logger.addHandler(pre_existing) + try: + importlib.reload(lf_logger) + assert httpx_logger.handlers == [pre_existing] + finally: + httpx_logger.handlers = saved diff --git a/tests/unit/test_openai.py b/tests/unit/test_openai.py index c06ce9df2..88783dd31 100644 --- a/tests/unit/test_openai.py +++ b/tests/unit/test_openai.py @@ -1051,3 +1051,15 @@ def test_with_raw_response_streaming_passes_through_untraced( span.name != "OpenAI-generation" for span in memory_exporter.get_finished_spans() ) + + +def test_default_response_handles_null_choices(): + for resource_type in ("chat", "completion"): + model, completion, usage = ( + lf_openai_module._get_langfuse_data_from_default_response( + SimpleNamespace(type=resource_type, object="ChatCompletion"), + {"model": "gpt-4", "choices": None, "usage": None}, + ) + ) + assert completion is None + assert model == "gpt-4"