diff --git a/langfuse/openai.py b/langfuse/openai.py index ea9dcd68c..94d7d1393 100644 --- a/langfuse/openai.py +++ b/langfuse/openai.py @@ -32,6 +32,7 @@ from inspect import isawaitable, isclass from typing import Any, Optional, cast +from openai import _types as openai_types from openai._types import NotGiven from packaging.version import Version from pydantic import BaseModel @@ -60,6 +61,13 @@ except ImportError: RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response" +_openai_omit_type = getattr(openai_types, "Omit", None) +_OPENAI_UNSET_TYPES: tuple[type[Any], ...] = ( + (NotGiven, _openai_omit_type) + if isinstance(_openai_omit_type, type) + else (NotGiven,) +) + @dataclass class OpenAiDefinition: @@ -207,7 +215,7 @@ class OpenAiDefinition: def _is_not_given(value: Any) -> bool: - return isinstance(value, NotGiven) + return isinstance(value, _OPENAI_UNSET_TYPES) def _get_attr_or_item(value: Any, key: str, default: Any = None) -> Any: @@ -356,13 +364,13 @@ def _extract_responses_prompt(kwargs: Any) -> Any: for key in _RESPONSES_PROMPT_FIELDS: value = kwargs.get(key, None) - if value is not None and not isinstance(value, NotGiven): + if value is not None and not _is_not_given(value): prompt_fields[key] = _serialize_openai_value(value) - if isinstance(input_value, NotGiven): + if _is_not_given(input_value): input_value = None - if isinstance(instructions, NotGiven): + if _is_not_given(instructions): instructions = None if instructions is None: @@ -395,14 +403,11 @@ def _extract_chat_prompt(kwargs: Any) -> Any: """Extracts the user input from prompts. Returns an array of messages or dict with messages and functions""" prompt = {} - if kwargs.get("functions") is not None: - prompt.update({"functions": kwargs["functions"]}) + for key in ("functions", "function_call", "tools"): + value = kwargs.get(key) - if kwargs.get("function_call") is not None: - prompt.update({"function_call": kwargs["function_call"]}) - - if kwargs.get("tools") is not None: - prompt.update({"tools": kwargs["tools"]}) + if value is not None and not _is_not_given(value): + prompt.update({key: value}) if prompt: # uf user provided functions, we need to send these together with messages to langfuse @@ -533,7 +538,7 @@ def _get_langfuse_data_from_kwargs(resource: OpenAiDefinition, kwargs: Any) -> A metadata = kwargs.get("metadata", {}) if ( metadata is not None - and not isinstance(metadata, NotGiven) + and not _is_not_given(metadata) and not isinstance(metadata, dict) ): if isinstance(metadata, BaseModel): @@ -556,63 +561,61 @@ def _get_langfuse_data_from_kwargs(resource: OpenAiDefinition, kwargs: Any) -> A parsed_temperature = ( kwargs.get("temperature", 1) - if not isinstance(kwargs.get("temperature", 1), NotGiven) + if not _is_not_given(kwargs.get("temperature", 1)) else 1 ) parsed_max_tokens = ( kwargs.get("max_tokens", float("inf")) - if not isinstance(kwargs.get("max_tokens", float("inf")), NotGiven) + if not _is_not_given(kwargs.get("max_tokens", float("inf"))) else float("inf") ) parsed_max_completion_tokens = ( kwargs.get("max_completion_tokens", None) - if not isinstance(kwargs.get("max_completion_tokens", float("inf")), NotGiven) + if not _is_not_given(kwargs.get("max_completion_tokens", float("inf"))) else None ) parsed_top_p = ( - kwargs.get("top_p", 1) - if not isinstance(kwargs.get("top_p", 1), NotGiven) - else 1 + kwargs.get("top_p", 1) if not _is_not_given(kwargs.get("top_p", 1)) else 1 ) parsed_frequency_penalty = ( kwargs.get("frequency_penalty", 0) - if not isinstance(kwargs.get("frequency_penalty", 0), NotGiven) + if not _is_not_given(kwargs.get("frequency_penalty", 0)) else 0 ) parsed_presence_penalty = ( kwargs.get("presence_penalty", 0) - if not isinstance(kwargs.get("presence_penalty", 0), NotGiven) + if not _is_not_given(kwargs.get("presence_penalty", 0)) else 0 ) parsed_seed = ( kwargs.get("seed", None) - if not isinstance(kwargs.get("seed", None), NotGiven) + if not _is_not_given(kwargs.get("seed", None)) else None ) - parsed_n = kwargs.get("n", 1) if not isinstance(kwargs.get("n", 1), NotGiven) else 1 + parsed_n = kwargs.get("n", 1) if not _is_not_given(kwargs.get("n", 1)) else 1 parsed_service_tier = ( kwargs.get("service_tier", None) - if not isinstance(kwargs.get("service_tier", None), NotGiven) + if not _is_not_given(kwargs.get("service_tier", None)) else None ) if resource.type == "embedding": parsed_dimensions = ( kwargs.get("dimensions", None) - if not isinstance(kwargs.get("dimensions", None), NotGiven) + if not _is_not_given(kwargs.get("dimensions", None)) else None ) parsed_encoding_format = ( kwargs.get("encoding_format", "float") - if not isinstance(kwargs.get("encoding_format", "float"), NotGiven) + if not _is_not_given(kwargs.get("encoding_format", "float")) else "float" ) @@ -1195,7 +1198,7 @@ def _get_raw_response_mode(kwargs: Any) -> Optional[str]: """ extra_headers = kwargs.get("extra_headers", None) - if extra_headers is None or isinstance(extra_headers, NotGiven): + if extra_headers is None or _is_not_given(extra_headers): return None try: diff --git a/tests/unit/test_openai_prompt_extraction.py b/tests/unit/test_openai_prompt_extraction.py index f4601f534..348202b14 100644 --- a/tests/unit/test_openai_prompt_extraction.py +++ b/tests/unit/test_openai_prompt_extraction.py @@ -11,14 +11,19 @@ except ImportError: from openai._types import NOT_GIVEN +from openai._types import Omit + from langfuse.openai import ( OpenAiArgsExtractor, OpenAiDefinition, + _extract_chat_prompt, _extract_responses_prompt, _get_langfuse_data_from_kwargs, _serialize_openai_value, ) +OMIT = Omit() + @pytest.mark.parametrize( "kwargs, expected", @@ -51,6 +56,22 @@ ), ({"instructions": NOT_GIVEN, "input": "Hello!"}, "Hello!"), ({"instructions": NOT_GIVEN, "input": NOT_GIVEN}, None), + ( + {"instructions": "You are helpful.", "input": OMIT}, + {"instructions": "You are helpful."}, + ), + ({"instructions": OMIT, "input": "Hello!"}, "Hello!"), + ({"instructions": OMIT, "input": OMIT}, None), + ( + { + "instructions": OMIT, + "input": "Hello!", + "tool_choice": OMIT, + "parallel_tool_calls": OMIT, + "tools": OMIT, + }, + "Hello!", + ), ( { "input": "Search for the weather in Berlin.", @@ -195,6 +216,64 @@ def test_store_preserves_user_structured_output_metadata_keys_for_openai(): assert openai_args["metadata"] is not metadata +def test_serialize_openai_value_treats_omit_as_not_given(): + assert _serialize_openai_value(OMIT) is None + assert _serialize_openai_value({"tool_choice": OMIT, "keep": 1}) == {"keep": 1} + + +@pytest.mark.parametrize("sentinel", [NOT_GIVEN, OMIT], ids=["not_given", "omit"]) +def test_extract_chat_prompt_excludes_unset_sentinel_tool_fields(sentinel): + messages = [{"role": "user", "content": "Hello!"}] + + prompt = _extract_chat_prompt( + { + "messages": messages, + "functions": sentinel, + "function_call": sentinel, + "tools": sentinel, + } + ) + + assert prompt == messages + + +@pytest.mark.parametrize("sentinel", [NOT_GIVEN, OMIT], ids=["not_given", "omit"]) +def test_unset_sentinel_kwargs_do_not_leak_into_langfuse_data(sentinel): + resource = OpenAiDefinition( + module="", + object="Completions", + method="create", + type="chat", + sync=True, + ) + args = OpenAiArgsExtractor( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello!"}], + metadata=sentinel, + temperature=sentinel, + top_p=sentinel, + max_tokens=sentinel, + frequency_penalty=sentinel, + presence_penalty=sentinel, + seed=sentinel, + n=sentinel, + service_tier=sentinel, + tools=sentinel, + ).get_langfuse_args() + + langfuse_data = _get_langfuse_data_from_kwargs(resource, args) + + assert langfuse_data["input"] == [{"role": "user", "content": "Hello!"}] + assert langfuse_data["metadata"] is None + assert langfuse_data["model_parameters"] == { + "temperature": 1, + "max_tokens": float("inf"), + "top_p": 1, + "frequency_penalty": 0, + "presence_penalty": 0, + } + + def test_store_does_not_forward_instrumentation_structured_output_metadata(): class ResponseFormat(BaseModel): name: str