diff --git a/logfire/_internal/integrations/llm_providers/openai.py b/logfire/_internal/integrations/llm_providers/openai.py index 39e708aeb..639c41d24 100644 --- a/logfire/_internal/integrations/llm_providers/openai.py +++ b/logfire/_internal/integrations/llm_providers/openai.py @@ -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), + # 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} else: # pragma: no cover # Return as generic dict for unknown types return {**part, 'type': part_type} @@ -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( diff --git a/tests/otel_integrations/test_openai.py b/tests/otel_integrations/test_openai.py index 17a0c5a6b..acd2d3cc5 100644 --- a/tests/otel_integrations/test_openai.py +++ b/tests/otel_integrations/test_openai.py @@ -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