Skip to content

Commit 87f8d57

Browse files
committed
feat(openai): add support for image generation tracking
Adds automatic tracing for OpenAI image generation API calls: - images.generate() - images.edit() - images.create_variation() For both sync and async clients. Tracks: - Prompt/input - Model (dall-e-2, dall-e-3, gpt-image-1) - Model parameters (size, quality, style, response_format, n) - Output (URLs or base64 images via LangfuseMedia) - Image count in usage_details Closes #8252
1 parent 54fff05 commit 87f8d57

2 files changed

Lines changed: 224 additions & 3 deletions

File tree

langfuse/openai.py

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,48 @@ class OpenAiDefinition:
9090
type="chat",
9191
sync=True,
9292
),
93+
OpenAiDefinition(
94+
module="openai.resources.images",
95+
object="Images",
96+
method="generate",
97+
type="image",
98+
sync=True,
99+
),
100+
OpenAiDefinition(
101+
module="openai.resources.images",
102+
object="AsyncImages",
103+
method="generate",
104+
type="image",
105+
sync=False,
106+
),
107+
OpenAiDefinition(
108+
module="openai.resources.images",
109+
object="Images",
110+
method="edit",
111+
type="image",
112+
sync=True,
113+
),
114+
OpenAiDefinition(
115+
module="openai.resources.images",
116+
object="AsyncImages",
117+
method="edit",
118+
type="image",
119+
sync=False,
120+
),
121+
OpenAiDefinition(
122+
module="openai.resources.images",
123+
object="Images",
124+
method="create_variation",
125+
type="image",
126+
sync=True,
127+
),
128+
OpenAiDefinition(
129+
module="openai.resources.images",
130+
object="AsyncImages",
131+
method="create_variation",
132+
type="image",
133+
sync=False,
134+
),
93135
OpenAiDefinition(
94136
module="openai.resources.completions",
95137
object="Completions",
@@ -354,9 +396,12 @@ def _extract_chat_response(kwargs: Any) -> Any:
354396

355397

356398
def _get_langfuse_data_from_kwargs(resource: OpenAiDefinition, kwargs: Any) -> Any:
357-
default_name = (
358-
"OpenAI-embedding" if resource.type == "embedding" else "OpenAI-generation"
359-
)
399+
if resource.type == "embedding":
400+
default_name = "OpenAI-embedding"
401+
elif resource.type == "image":
402+
default_name = "OpenAI-image"
403+
else:
404+
default_name = "OpenAI-generation"
360405
name = kwargs.get("name", default_name)
361406

362407
if name is None:
@@ -417,6 +462,8 @@ def _get_langfuse_data_from_kwargs(resource: OpenAiDefinition, kwargs: Any) -> A
417462
prompt = _extract_chat_prompt(kwargs)
418463
elif resource.type == "embedding":
419464
prompt = kwargs.get("input", None)
465+
elif resource.type == "image":
466+
prompt = kwargs.get("prompt", None)
420467

421468
parsed_temperature = (
422469
kwargs.get("temperature", 1)
@@ -479,6 +526,44 @@ def _get_langfuse_data_from_kwargs(resource: OpenAiDefinition, kwargs: Any) -> A
479526
modelParameters["dimensions"] = parsed_dimensions
480527
if parsed_encoding_format != "float":
481528
modelParameters["encoding_format"] = parsed_encoding_format
529+
elif resource.type == "image":
530+
# Image generation parameters
531+
modelParameters = {}
532+
533+
parsed_size = (
534+
kwargs.get("size", None)
535+
if not isinstance(kwargs.get("size", None), NotGiven)
536+
else None
537+
)
538+
if parsed_size is not None:
539+
modelParameters["size"] = parsed_size
540+
541+
parsed_quality = (
542+
kwargs.get("quality", None)
543+
if not isinstance(kwargs.get("quality", None), NotGiven)
544+
else None
545+
)
546+
if parsed_quality is not None:
547+
modelParameters["quality"] = parsed_quality
548+
549+
parsed_style = (
550+
kwargs.get("style", None)
551+
if not isinstance(kwargs.get("style", None), NotGiven)
552+
else None
553+
)
554+
if parsed_style is not None:
555+
modelParameters["style"] = parsed_style
556+
557+
parsed_response_format = (
558+
kwargs.get("response_format", None)
559+
if not isinstance(kwargs.get("response_format", None), NotGiven)
560+
else None
561+
)
562+
if parsed_response_format is not None:
563+
modelParameters["response_format"] = parsed_response_format
564+
565+
if parsed_n is not None and isinstance(parsed_n, int) and parsed_n > 1:
566+
modelParameters["n"] = parsed_n
482567
else:
483568
modelParameters = {
484569
"temperature": parsed_temperature,
@@ -791,6 +876,33 @@ def _get_langfuse_data_from_default_response(
791876
"count": len(data),
792877
}
793878

879+
elif resource.type == "image":
880+
data = response.get("data", [])
881+
completion = []
882+
for item in data:
883+
image_data = item.__dict__ if hasattr(item, "__dict__") else item
884+
image_result = {}
885+
886+
# Handle URL response
887+
if image_data.get("url"):
888+
image_result["url"] = image_data["url"]
889+
890+
# Handle base64 response
891+
if image_data.get("b64_json"):
892+
# Wrap in LangfuseMedia for proper handling
893+
base64_data_uri = f"data:image/png;base64,{image_data['b64_json']}"
894+
image_result["image"] = LangfuseMedia(base64_data_uri=base64_data_uri)
895+
896+
# Include revised_prompt if present (DALL-E 3)
897+
if image_data.get("revised_prompt"):
898+
image_result["revised_prompt"] = image_data["revised_prompt"]
899+
900+
completion.append(image_result)
901+
902+
# If only one image, unwrap from list
903+
if len(completion) == 1:
904+
completion = completion[0]
905+
794906
usage = _parse_usage(response.get("usage", None))
795907

796908
return (model, completion, usage)
@@ -842,6 +954,28 @@ def _wrap(
842954
try:
843955
openai_response = wrapped(**arg_extractor.get_openai_args())
844956

957+
# Handle image generation (non-streaming)
958+
if open_ai_resource.type == "image":
959+
model, completion, usage = _get_langfuse_data_from_default_response(
960+
open_ai_resource,
961+
(openai_response and openai_response.__dict__)
962+
if _is_openai_v1()
963+
else openai_response,
964+
)
965+
966+
# Calculate image count for usage tracking
967+
image_count = 1
968+
if isinstance(completion, list):
969+
image_count = len(completion)
970+
971+
generation.update(
972+
model=model,
973+
output=completion,
974+
usage_details={"output": image_count, "total": image_count, "unit": "IMAGES"},
975+
).end()
976+
977+
return openai_response
978+
845979
if _is_streaming_response(openai_response):
846980
return LangfuseResponseGeneratorSync(
847981
resource=open_ai_resource,
@@ -913,6 +1047,28 @@ async def _wrap_async(
9131047
try:
9141048
openai_response = await wrapped(**arg_extractor.get_openai_args())
9151049

1050+
# Handle image generation (non-streaming)
1051+
if open_ai_resource.type == "image":
1052+
model, completion, usage = _get_langfuse_data_from_default_response(
1053+
open_ai_resource,
1054+
(openai_response and openai_response.__dict__)
1055+
if _is_openai_v1()
1056+
else openai_response,
1057+
)
1058+
1059+
# Calculate image count for usage tracking
1060+
image_count = 1
1061+
if isinstance(completion, list):
1062+
image_count = len(completion)
1063+
1064+
generation.update(
1065+
model=model,
1066+
output=completion,
1067+
usage_details={"output": image_count, "total": image_count, "unit": "IMAGES"},
1068+
).end()
1069+
1070+
return openai_response
1071+
9161072
if _is_streaming_response(openai_response):
9171073
return LangfuseResponseGeneratorAsync(
9181074
resource=open_ai_resource,

tests/test_openai.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,3 +1594,68 @@ async def test_async_openai_embeddings(openai):
15941594
assert embedding_data.metadata["async"] is True
15951595
assert embedding_data.usage.input is not None
15961596
assert embedding_data.usage.total is not None
1597+
1598+
1599+
def test_openai_image_generation(openai):
1600+
"""Test that image generation calls are tracked in Langfuse."""
1601+
generation_name = "test_image_generation_" + create_uuid()[:8]
1602+
1603+
response = openai.OpenAI().images.generate(
1604+
name=generation_name,
1605+
model="dall-e-3",
1606+
prompt="A white siamese cat",
1607+
size="1024x1024",
1608+
quality="standard",
1609+
n=1,
1610+
metadata={"test_key": "test_value"},
1611+
)
1612+
1613+
langfuse.flush()
1614+
sleep(1)
1615+
1616+
generation = get_api().observations.get_many(name=generation_name, type="GENERATION")
1617+
1618+
assert len(generation.data) != 0
1619+
generation_data = generation.data[0]
1620+
assert generation_data.name == generation_name
1621+
assert generation_data.metadata["test_key"] == "test_value"
1622+
assert generation_data.input == "A white siamese cat"
1623+
assert generation_data.type == "GENERATION"
1624+
assert "dall-e-3" in generation_data.model
1625+
assert generation_data.start_time is not None
1626+
assert generation_data.end_time is not None
1627+
assert generation_data.start_time < generation_data.end_time
1628+
assert generation_data.output is not None
1629+
# Check model parameters
1630+
assert generation_data.model_parameters is not None
1631+
assert generation_data.model_parameters.get("size") == "1024x1024"
1632+
assert generation_data.model_parameters.get("quality") == "standard"
1633+
1634+
1635+
@pytest.mark.asyncio
1636+
async def test_openai_image_generation_async(openai):
1637+
"""Test that async image generation calls are tracked in Langfuse."""
1638+
generation_name = "test_image_generation_async_" + create_uuid()[:8]
1639+
1640+
response = await openai.AsyncOpenAI().images.generate(
1641+
name=generation_name,
1642+
model="dall-e-3",
1643+
prompt="A sunset over mountains",
1644+
size="1024x1024",
1645+
quality="standard",
1646+
n=1,
1647+
metadata={"async": True},
1648+
)
1649+
1650+
langfuse.flush()
1651+
sleep(1)
1652+
1653+
generation = get_api().observations.get_many(name=generation_name, type="GENERATION")
1654+
1655+
assert len(generation.data) != 0
1656+
generation_data = generation.data[0]
1657+
assert generation_data.name == generation_name
1658+
assert generation_data.metadata["async"] is True
1659+
assert generation_data.input == "A sunset over mountains"
1660+
assert generation_data.type == "GENERATION"
1661+
assert "dall-e-3" in generation_data.model

0 commit comments

Comments
 (0)