Skip to content
Open
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
17 changes: 14 additions & 3 deletions logfire/_internal/integrations/llm_providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,17 +282,28 @@ def _convert_content_part(part: object) -> MessagePart:

part = cast('dict[str, Any]', part)
part_type = part.get('type', 'unknown')
if part_type in ('text', 'output_text'):
if part_type in ('text', 'output_text', 'input_text'):
return TextPart(type='text', content=part.get('text', ''))
elif part_type == 'image_url': # pragma: no cover
url = part.get('image_url', {}).get('url', '')
return UriPart(type='uri', uri=url, modality='image')
elif part_type == 'input_image':
# Responses API: image_url is a flat string (URL or data URI),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-input-messages.json specifies that blob parts with the actual bytes should be used instead of data URIs

# not the nested {url: ...} dict used by Chat Completions.
return UriPart(type='uri', uri=part.get('image_url', ''), modality='image')
elif part_type == 'input_audio': # pragma: no cover
return BlobPart(
type='blob',
content=part.get('input_audio', {}).get('data', ''),
modality='audio',
)
elif part_type == 'input_file':
# Responses API input_file may carry file_url, file_data (data URI),
# or just file_id. Prefer URI-bearing fields; fall through for file_id.
uri = part.get('file_url') or part.get('file_data')
if uri:
return UriPart(type='uri', uri=uri, modality='document')
return {**part, 'type': part_type}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a FilePart in https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-input-messages.json, it should be added and used here

else: # pragma: no cover
# Return as generic dict for unknown types
return {**part, 'type': part_type}
Expand Down Expand Up @@ -728,10 +739,10 @@ def input_to_events(inp: dict[str, Any], tool_call_id_to_name: dict[str, str]):
else:
for content_item in content:
with contextlib.suppress(KeyError):
if content_item['type'] == 'output_text':
if content_item['type'] in ('output_text', 'input_text'):
events.append({'event.name': event_name, 'content': content_item['text'], 'role': role})
continue
events.append(unknown_event(content_item)) # pragma: no cover
events.append(unknown_event(content_item))
elif typ == 'function_call':
tool_call_id_to_name[inp['call_id']] = inp['name']
events.append(
Expand Down
89 changes: 89 additions & 0 deletions tests/otel_integrations/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -4319,6 +4319,95 @@ def test_convert_responses_inputs_no_inputs() -> None:
assert (input_messages, system_instructions) == snapshot(([], [{'type': 'text', 'content': 'Be helpful'}]))


def test_convert_responses_inputs_input_text() -> None:
"""Responses API `input_text` content parts should map to TextPart."""
from logfire._internal.integrations.llm_providers.openai import convert_responses_inputs_to_semconv

inputs: list[dict[str, Any]] = [{'role': 'user', 'content': [{'type': 'input_text', 'text': 'Hello there'}]}]
input_messages, system_instructions = convert_responses_inputs_to_semconv(inputs, None)
assert (input_messages, system_instructions) == snapshot(
([{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello there'}]}], [])
)


def test_convert_responses_inputs_input_image() -> None:
"""Responses API `input_image` content parts should map to UriPart with modality=image."""
from logfire._internal.integrations.llm_providers.openai import convert_responses_inputs_to_semconv

inputs: list[dict[str, Any]] = [
{
'role': 'user',
'content': [
{'type': 'input_text', 'text': 'What is in this image?'},
{'type': 'input_image', 'image_url': 'https://example.com/cat.jpg'},
],
}
]
input_messages, system_instructions = convert_responses_inputs_to_semconv(inputs, None)
assert (input_messages, system_instructions) == snapshot(
(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'What is in this image?'},
{'type': 'uri', 'uri': 'https://example.com/cat.jpg', 'modality': 'image'},
],
}
],
[],
)
)


def test_convert_responses_inputs_input_file() -> None:
"""Responses API `input_file` should map to UriPart for file_url / file_data, dict for file_id-only."""
from logfire._internal.integrations.llm_providers.openai import convert_responses_inputs_to_semconv

inputs: list[dict[str, Any]] = [
{
'role': 'user',
'content': [
{'type': 'input_file', 'file_url': 'https://example.com/doc.pdf'},
{'type': 'input_file', 'file_data': 'data:application/pdf;base64,JVBERi0x'},
{'type': 'input_file', 'file_id': 'file-abc123'},
],
}
]
input_messages, system_instructions = convert_responses_inputs_to_semconv(inputs, None)
assert (input_messages, system_instructions) == snapshot(
(
[
{
'role': 'user',
'parts': [
{'type': 'uri', 'uri': 'https://example.com/doc.pdf', 'modality': 'document'},
{
'type': 'uri',
'uri': 'data:application/pdf;base64,JVBERi0x',
'modality': 'document',
},
{'type': 'input_file', 'file_id': 'file-abc123'},
],
}
],
[],
)
)


def test_input_to_events_input_text() -> None:
"""input_to_events should recognize Responses API `input_text` (legacy semconv path)."""
from logfire._internal.integrations.llm_providers.openai import input_to_events

inp: dict[str, Any] = {
'role': 'user',
'content': [{'type': 'input_text', 'text': 'Hello there'}],
}
events = input_to_events(inp, {})
assert events == snapshot([{'event.name': 'gen_ai.user.message', 'content': 'Hello there', 'role': 'user'}])


def test_convert_responses_inputs_function_call_non_string_args() -> None:
"""Test convert_responses_inputs_to_semconv with function_call with dict arguments."""
from logfire._internal.integrations.llm_providers.openai import convert_responses_inputs_to_semconv
Expand Down
Loading