Skip to content

Commit fd2deaa

Browse files
authored
fix(openai): parse raw API responses and add flag to skip tracing them (#1740)
1 parent 5a32d64 commit fd2deaa

3 files changed

Lines changed: 267 additions & 8 deletions

File tree

langfuse/_client/environment_variables.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,17 @@
156156
157157
**Default value**: ``60``
158158
"""
159+
160+
LANGFUSE_OPENAI_SKIP_RAW_RESPONSES = "LANGFUSE_OPENAI_SKIP_RAW_RESPONSES"
161+
"""
162+
.. envvar: LANGFUSE_OPENAI_SKIP_RAW_RESPONSES
163+
164+
Controls whether the OpenAI integration skips instrumenting calls made via the
165+
OpenAI SDK's `.with_raw_response` and `.with_streaming_response` APIs.
166+
167+
Set this to `True` when another instrumented library calls the OpenAI SDK
168+
internally through the raw-response API (e.g. LiteLLM with the `langfuse_otel`
169+
callback) to avoid duplicate observations for the same LLM call.
170+
171+
**Default value**: ``False``
172+
"""

langfuse/openai.py

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@
1414
1515
The integration is fully interoperable with the `observe()` decorator and the low-level tracing SDK.
1616
17+
Calls made via the OpenAI SDK's `.with_raw_response` API are traced as well, except for
18+
raw streaming calls which are passed through untraced. Set the environment variable
19+
`LANGFUSE_OPENAI_SKIP_RAW_RESPONSES=True` to exclude all raw-response calls from tracing,
20+
e.g. when another instrumented library (such as LiteLLM) calls the OpenAI SDK internally
21+
through the raw-response API and would otherwise produce duplicate observations.
22+
1723
See docs for more details: https://langfuse.com/docs/integrations/openai
1824
"""
1925

2026
import json
27+
import os
2128
import types
2229
from collections import defaultdict
2330
from dataclasses import dataclass
@@ -31,6 +38,9 @@
3138
from pydantic_core import to_jsonable_python
3239
from wrapt import wrap_function_wrapper
3340

41+
from langfuse._client.environment_variables import (
42+
LANGFUSE_OPENAI_SKIP_RAW_RESPONSES,
43+
)
3444
from langfuse._client.get_client import get_client
3545
from langfuse._client.span import LangfuseGeneration
3646
from langfuse._utils import _get_timestamp
@@ -45,6 +55,11 @@
4555
"Please install OpenAI to use this feature: 'pip install openai'"
4656
)
4757

58+
try:
59+
from openai._constants import RAW_RESPONSE_HEADER
60+
except ImportError:
61+
RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response"
62+
4863

4964
@dataclass
5065
class OpenAiDefinition:
@@ -1128,11 +1143,73 @@ async def traced_aclose() -> Any:
11281143
return response
11291144

11301145

1146+
def _get_raw_response_mode(kwargs: Any) -> Optional[str]:
1147+
"""Return the value of the OpenAI SDK's internal raw-response sentinel header.
1148+
1149+
The SDK's `.with_raw_response` wrapper sets it to "true" and
1150+
`.with_streaming_response` sets it to "stream" before invoking the same
1151+
resource method that Langfuse instruments. Returns None for regular calls.
1152+
"""
1153+
extra_headers = kwargs.get("extra_headers", None)
1154+
1155+
if extra_headers is None or isinstance(extra_headers, NotGiven):
1156+
return None
1157+
1158+
try:
1159+
return cast(Optional[str], extra_headers.get(RAW_RESPONSE_HEADER, None))
1160+
except AttributeError:
1161+
return None
1162+
1163+
1164+
def _should_skip_raw_response_instrumentation(kwargs: Any) -> bool:
1165+
raw_response_mode = _get_raw_response_mode(kwargs)
1166+
1167+
if raw_response_mode is None:
1168+
return False
1169+
1170+
if os.environ.get(LANGFUSE_OPENAI_SKIP_RAW_RESPONSES, "False").lower() in (
1171+
"true",
1172+
"1",
1173+
):
1174+
return True
1175+
1176+
# Raw streaming responses cannot be instrumented without consuming the
1177+
# caller's stream or raw body, so they are always passed through untraced.
1178+
return raw_response_mode == "stream" or kwargs.get("stream", False) is True
1179+
1180+
1181+
def _unwrap_raw_response(openai_response: Any) -> Any:
1182+
"""Return the parsed model for raw API responses so data extraction works.
1183+
1184+
Libraries wrapping the OpenAI SDK (e.g. LiteLLM) call it via
1185+
`.with_raw_response`, in which case the instrumented method returns a raw
1186+
response object instead of the parsed model. `.parse()` caches its result
1187+
on the response, so callers parsing later are unaffected.
1188+
"""
1189+
if openai_response is None:
1190+
return openai_response
1191+
1192+
try:
1193+
from openai._legacy_response import LegacyAPIResponse
1194+
from openai._response import APIResponse
1195+
1196+
if isinstance(openai_response, (LegacyAPIResponse, APIResponse)):
1197+
return openai_response.parse()
1198+
except Exception as e:
1199+
logger.debug(f"Failed to parse raw OpenAI response for tracing: {e}")
1200+
1201+
return openai_response
1202+
1203+
11311204
@_langfuse_wrapper
11321205
def _wrap(
11331206
open_ai_resource: OpenAiDefinition, wrapped: Any, args: Any, kwargs: Any
11341207
) -> Any:
11351208
arg_extractor = OpenAiArgsExtractor(*args, **kwargs)
1209+
1210+
if _should_skip_raw_response_instrumentation(kwargs):
1211+
return wrapped(**arg_extractor.get_openai_args())
1212+
11361213
langfuse_args = arg_extractor.get_langfuse_args()
11371214

11381215
langfuse_data = _get_langfuse_data_from_kwargs(open_ai_resource, langfuse_args)
@@ -1175,19 +1252,20 @@ def _wrap(
11751252
)
11761253

11771254
else:
1255+
parsed_response = _unwrap_raw_response(openai_response)
11781256
model, completion, usage = _get_langfuse_data_from_default_response(
11791257
open_ai_resource,
1180-
(openai_response and openai_response.__dict__)
1258+
(parsed_response and parsed_response.__dict__)
11811259
if _is_openai_v1()
1182-
else openai_response,
1260+
else parsed_response,
11831261
)
11841262

11851263
generation.update(
11861264
model=model,
11871265
output=completion,
11881266
usage_details=usage,
1189-
cost_details=_parse_cost(openai_response.usage)
1190-
if hasattr(openai_response, "usage")
1267+
cost_details=_parse_cost(parsed_response.usage)
1268+
if hasattr(parsed_response, "usage")
11911269
else None,
11921270
).end()
11931271

@@ -1210,6 +1288,10 @@ async def _wrap_async(
12101288
open_ai_resource: OpenAiDefinition, wrapped: Any, args: Any, kwargs: Any
12111289
) -> Any:
12121290
arg_extractor = OpenAiArgsExtractor(*args, **kwargs)
1291+
1292+
if _should_skip_raw_response_instrumentation(kwargs):
1293+
return await wrapped(**arg_extractor.get_openai_args())
1294+
12131295
langfuse_args = arg_extractor.get_langfuse_args()
12141296

12151297
langfuse_data = _get_langfuse_data_from_kwargs(open_ai_resource, langfuse_args)
@@ -1252,19 +1334,20 @@ async def _wrap_async(
12521334
)
12531335

12541336
else:
1337+
parsed_response = _unwrap_raw_response(openai_response)
12551338
model, completion, usage = _get_langfuse_data_from_default_response(
12561339
open_ai_resource,
1257-
(openai_response and openai_response.__dict__)
1340+
(parsed_response and parsed_response.__dict__)
12581341
if _is_openai_v1()
1259-
else openai_response,
1342+
else parsed_response,
12601343
)
12611344
generation.update(
12621345
model=model,
12631346
output=completion,
12641347
usage=usage, # backward compat for all V2 self hosters
12651348
usage_details=usage,
1266-
cost_details=_parse_cost(openai_response.usage)
1267-
if hasattr(openai_response, "usage")
1349+
cost_details=_parse_cost(parsed_response.usage)
1350+
if hasattr(parsed_response, "usage")
12681351
else None,
12691352
).end()
12701353

tests/unit/test_openai.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,3 +889,165 @@ def test_embedding_exports_dimensions_and_count(
889889
assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == {
890890
"input": 2
891891
}
892+
893+
894+
def _chat_completion_payload():
895+
return {
896+
"id": "chatcmpl-test",
897+
"object": "chat.completion",
898+
"created": 1700000000,
899+
"model": "gpt-4o-mini-2024-07-18",
900+
"choices": [
901+
{
902+
"index": 0,
903+
"message": {"role": "assistant", "content": "2"},
904+
"finish_reason": "stop",
905+
}
906+
],
907+
"usage": {
908+
"prompt_tokens": 10,
909+
"completion_tokens": 1,
910+
"total_tokens": 11,
911+
"prompt_tokens_details": {"cached_tokens": 4, "audio_tokens": 0},
912+
},
913+
}
914+
915+
916+
def _chat_completion_chunk_sse_body():
917+
return (
918+
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk",'
919+
'"created":1700000000,"model":"gpt-4o-mini-2024-07-18",'
920+
'"choices":[{"index":0,"delta":{"role":"assistant","content":"2"},'
921+
'"finish_reason":null}]}\n\n'
922+
"data: [DONE]\n\n"
923+
)
924+
925+
926+
def _mock_transport_openai_client(async_client: bool = False):
927+
import httpx
928+
929+
def handler(request: httpx.Request) -> httpx.Response:
930+
if b'"stream": true' in request.content or b'"stream":true' in request.content:
931+
return httpx.Response(
932+
200,
933+
content=_chat_completion_chunk_sse_body().encode(),
934+
headers={"content-type": "text/event-stream"},
935+
)
936+
937+
return httpx.Response(200, json=_chat_completion_payload())
938+
939+
if async_client:
940+
return lf_openai.AsyncOpenAI(
941+
api_key="test",
942+
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
943+
)
944+
945+
return lf_openai.OpenAI(
946+
api_key="test",
947+
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
948+
)
949+
950+
951+
def test_with_raw_response_chat_completion_captures_output_and_usage(
952+
langfuse_memory_client, get_span, json_attr
953+
):
954+
openai_client = _mock_transport_openai_client()
955+
956+
raw_response = openai_client.chat.completions.with_raw_response.create(
957+
model="gpt-4o-mini",
958+
messages=[{"role": "user", "content": "1 + 1 = ?"}],
959+
)
960+
961+
parsed = raw_response.parse()
962+
assert parsed.choices[0].message.content == "2"
963+
964+
langfuse_memory_client.flush()
965+
span = get_span("OpenAI-generation")
966+
967+
assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] == "generation"
968+
assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT) == {
969+
"role": "assistant",
970+
"content": "2",
971+
}
972+
973+
usage = json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS)
974+
assert usage["prompt_tokens"] == 10
975+
assert usage["completion_tokens"] == 1
976+
assert usage["total_tokens"] == 11
977+
assert usage["prompt_tokens_details"] == {"cached_tokens": 4, "audio_tokens": 0}
978+
979+
980+
@pytest.mark.asyncio
981+
async def test_async_with_raw_response_chat_completion_captures_output_and_usage(
982+
langfuse_memory_client, get_span, json_attr
983+
):
984+
openai_client = _mock_transport_openai_client(async_client=True)
985+
986+
raw_response = await openai_client.chat.completions.with_raw_response.create(
987+
model="gpt-4o-mini",
988+
messages=[{"role": "user", "content": "1 + 1 = ?"}],
989+
)
990+
991+
parsed = raw_response.parse()
992+
assert parsed.choices[0].message.content == "2"
993+
994+
langfuse_memory_client.flush()
995+
span = get_span("OpenAI-generation")
996+
997+
assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT) == {
998+
"role": "assistant",
999+
"content": "2",
1000+
}
1001+
1002+
usage = json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS)
1003+
assert usage["prompt_tokens_details"] == {"cached_tokens": 4, "audio_tokens": 0}
1004+
1005+
1006+
def test_with_raw_response_skip_flag_disables_instrumentation(
1007+
langfuse_memory_client, memory_exporter, get_span, monkeypatch
1008+
):
1009+
monkeypatch.setenv("LANGFUSE_OPENAI_SKIP_RAW_RESPONSES", "True")
1010+
openai_client = _mock_transport_openai_client()
1011+
1012+
raw_response = openai_client.chat.completions.with_raw_response.create(
1013+
model="gpt-4o-mini",
1014+
messages=[{"role": "user", "content": "1 + 1 = ?"}],
1015+
)
1016+
assert raw_response.parse().choices[0].message.content == "2"
1017+
1018+
langfuse_memory_client.flush()
1019+
assert all(
1020+
span.name != "OpenAI-generation"
1021+
for span in memory_exporter.get_finished_spans()
1022+
)
1023+
1024+
openai_client.chat.completions.create(
1025+
name="unit-openai-direct-with-skip-flag",
1026+
model="gpt-4o-mini",
1027+
messages=[{"role": "user", "content": "1 + 1 = ?"}],
1028+
)
1029+
1030+
langfuse_memory_client.flush()
1031+
span = get_span("unit-openai-direct-with-skip-flag")
1032+
assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] == "generation"
1033+
1034+
1035+
def test_with_raw_response_streaming_passes_through_untraced(
1036+
langfuse_memory_client, memory_exporter
1037+
):
1038+
openai_client = _mock_transport_openai_client()
1039+
1040+
raw_response = openai_client.chat.completions.with_raw_response.create(
1041+
model="gpt-4o-mini",
1042+
messages=[{"role": "user", "content": "1 + 1 = ?"}],
1043+
stream=True,
1044+
)
1045+
1046+
chunks = list(raw_response.parse())
1047+
assert chunks[0].choices[0].delta.content == "2"
1048+
1049+
langfuse_memory_client.flush()
1050+
assert all(
1051+
span.name != "OpenAI-generation"
1052+
for span in memory_exporter.get_finished_spans()
1053+
)

0 commit comments

Comments
 (0)