Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 29 additions & 26 deletions langfuse/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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"
)

Expand Down Expand Up @@ -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:
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/test_openai_prompt_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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
Expand Down
Loading