diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index db03045dbe..85c461e7dd 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -621,6 +621,28 @@ async def test_chat_message_parsing_with_function_calls() -> None: ] +@pytest.mark.parametrize( + ("output", "expected"), + [ + pytest.param([], "", id="empty-list"), + pytest.param([{"value": 1}], '[{"value": 1}]', id="non-empty-list"), + ], +) +def test_foundry_function_result_list_respects_rich_output_capability( + output: list[dict[str, Any]], + expected: str, +) -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + content = Content("function_result", call_id="test-call-id", result=output) + + result = client._prepare_content_for_openai("user", content) + + assert result["output"] == expected + + async def test_content_filter_exception() -> None: mock_openai_client = _make_mock_openai_client() project_client = MagicMock() diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 55ff20950a..f81d7e7289 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1985,7 +1985,20 @@ def _prepare_content_for_openai( "output": self._to_local_shell_output_payload(content), } # call_id for the result needs to be the same as the call_id for the function call - output: str | list[dict[str, Any]] = content.result or "" + raw_result: Any = content.result + if isinstance(raw_result, str): + output: str | list[Any] = raw_result + elif isinstance(raw_result, list) and self.SUPPORTS_RICH_FUNCTION_OUTPUT: + output = cast("list[Any]", raw_result) + elif raw_result is None or ( + isinstance(raw_result, list) and not self.SUPPORTS_RICH_FUNCTION_OUTPUT and not raw_result + ): + output = "" + else: + try: + output = json.dumps(cast("Any", raw_result), default=str) + except (TypeError, ValueError): + output = str(cast("Any", raw_result)) if ( self.SUPPORTS_RICH_FUNCTION_OUTPUT and content.items diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index dda0491320..79bf04ec29 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -933,17 +933,17 @@ def _parse_usage_from_openai(self, usage: CompletionUsage) -> UsageDetails: total_token_count=usage.total_tokens, ) if usage.completion_tokens_details: - if tokens := usage.completion_tokens_details.accepted_prediction_tokens: + if (tokens := usage.completion_tokens_details.accepted_prediction_tokens) is not None: details["completion/accepted_prediction_tokens"] = tokens - if tokens := usage.completion_tokens_details.audio_tokens: + if (tokens := usage.completion_tokens_details.audio_tokens) is not None: details["completion/audio_tokens"] = tokens if (tokens := usage.completion_tokens_details.reasoning_tokens) is not None: details["completion/reasoning_tokens"] = tokens details["reasoning_output_token_count"] = tokens - if tokens := usage.completion_tokens_details.rejected_prediction_tokens: + if (tokens := usage.completion_tokens_details.rejected_prediction_tokens) is not None: details["completion/rejected_prediction_tokens"] = tokens if usage.prompt_tokens_details: - if tokens := usage.prompt_tokens_details.audio_tokens: + if (tokens := usage.prompt_tokens_details.audio_tokens) is not None: details["prompt/audio_tokens"] = tokens cache_write_tokens = cast("int | None", getattr(usage.prompt_tokens_details, "cache_write_tokens", None)) if cache_write_tokens is not None: @@ -1122,10 +1122,12 @@ def _build_openai_messages(self, message: Message) -> list[dict[str, Any]]: continue args["content"] = [{"type": "text", "text": content.text}] case _: - if "content" not in args: - args["content"] = [] - # this is a list to allow multi-modal content - args["content"].append(self._prepare_content_for_openai(content)) # type: ignore + prepared_content = self._prepare_content_for_openai(content) + if prepared_content: + if "content" not in args: + args["content"] = [] + # this is a list to allow multi-modal content + args["content"].append(prepared_content) # type: ignore if "content" in args or "tool_calls" in args: if pending_reasoning is not None: args["reasoning_details"] = pending_reasoning @@ -1211,8 +1213,8 @@ def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: elif content.media_type and "mp3" in content.media_type: audio_format = "mp3" else: - # Fallback to default to_dict for unsupported audio formats - return content.to_dict(exclude_none=True) + logger.debug("Unsupported audio media type: %s", content.media_type) + return {} # Extract base64 data from data URI audio_data = content.uri @@ -1248,8 +1250,8 @@ def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]: content, ) case _: - # Default fallback for all other content types - return content.to_dict(exclude_none=True) + logger.debug("Unsupported content type passed (type: %s)", content.type) + return {} @override def service_url(self) -> str: diff --git a/python/packages/openai/agent_framework_openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py index 572e2437cb..d2b84d1f42 100644 --- a/python/packages/openai/agent_framework_openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -291,7 +291,7 @@ async def get_embeddings( encoding = kwargs.get("encoding_format", "float") embeddings: list[Embedding[list[float]]] = [] - for item in response.data: + for item in sorted(response.data, key=lambda item: item.index): vector: list[float] if encoding == "base64" and isinstance(item.embedding, str): # Decode base64-encoded floats (little-endian IEEE 754) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index aab0fc1fe2..eb37983c4e 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -5585,6 +5585,36 @@ def test_prepare_content_for_openai_function_result_with_rich_items() -> None: assert output[1]["type"] == "input_image" +@pytest.mark.parametrize( + "output", + ["", [], [{"type": "input_text", "text": "result"}]], + ids=["empty-string", "empty-list", "non-empty-list"], +) +def test_prepare_content_for_openai_preserves_supported_function_output( + output: str | list[dict[str, Any]], +) -> None: + client = OpenAIChatClient(model="test-model", api_key="test-key") + content = Content("function_result", call_id="call_falsey", result=output) + + result = client._prepare_content_for_openai("user", content) + + assert result["output"] == output + + +@pytest.mark.parametrize( + ("output", "expected"), + [(False, "false"), (0, "0"), ({}, "{}")], + ids=["false", "zero", "empty-dict"], +) +def test_prepare_content_for_openai_normalizes_unsupported_falsey_function_output(output: Any, expected: str) -> None: + client = OpenAIChatClient(model="test-model", api_key="test-key") + content = Content("function_result", call_id="call_falsey", result=output) + + result = client._prepare_content_for_openai("user", content) + + assert result["output"] == expected + + def test_prepare_content_for_openai_function_result_without_items() -> None: """Test _prepare_content_for_openai with plain string function_result.""" client = OpenAIChatClient(model="test-model", api_key="test-key") diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index d63f3a3e10..2ff3e4d459 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -549,15 +549,13 @@ def test_prepare_content_for_openai_data_content_image( assert result["type"] == "image_url" assert result["image_url"]["url"] == image_data_content.uri - # Test DataContent with non-image media type should use default model_dump + # Test DataContent with non-image media type is omitted instead of emitting + # Agent Framework's internal content shape. text_data_content = Content.from_uri(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain") result = client._prepare_content_for_openai(text_data_content) # type: ignore - # Should use default model_dump format - assert result["type"] == "data" - assert result["uri"] == text_data_content.uri - assert result["media_type"] == "text/plain" + assert result == {} # Test DataContent with audio media type audio_data_content = Content.from_uri( @@ -587,6 +585,22 @@ def test_prepare_content_for_openai_data_content_image( assert result["input_audio"]["data"] == "//uQAAAAWGluZwAAAA8AAAACAAACcQ==" assert result["input_audio"]["format"] == "mp3" + unsupported_audio = Content.from_uri(uri="data:audio/ogg;base64,abc123", media_type="audio/ogg") + + assert client._prepare_content_for_openai(unsupported_audio) == {} # type: ignore + + +def test_prepare_message_for_openai_omits_unsupported_content() -> None: + client = OpenAIChatCompletionClient(model="test-model", api_key="test-key") + unsupported = Content.from_uri(uri="data:text/plain;base64,SGVsbG8=", media_type="text/plain") + + prepared = client._prepare_message_for_openai( + Message(role="user", contents=[unsupported, Content.from_text("supported")]) + ) + + assert prepared == [{"role": "user", "content": "supported"}] + assert client._prepare_message_for_openai(Message(role="user", contents=[unsupported])) == [] + def test_prepare_content_for_openai_image_url_detail( openai_unit_test_env: dict[str, str], @@ -1181,13 +1195,13 @@ def test_mixed_approval_resume_roles_serialize_function_result_as_tool( prepared = client._prepare_messages_for_openai(messages) - assert prepared[0] == { - "role": "tool", - "tool_call_id": "call_completed", - "content": "completed", - } - assert prepared[1]["role"] == "assistant" - assert "tool_call_id" not in prepared[1] + assert prepared == [ + { + "role": "tool", + "tool_call_id": "call_completed", + "content": "completed", + } + ] def test_usage_content_in_streaming_response( @@ -1228,8 +1242,8 @@ def test_usage_content_in_streaming_response( assert usage_content.usage_details["total_token_count"] == 150 -def test_parse_usage_includes_standard_and_legacy_mapped_token_details() -> None: - """Test _parse_usage_from_openai emits standard and legacy mapped token details.""" +def test_parse_usage_preserves_zero_valued_optional_token_details() -> None: + """Test _parse_usage_from_openai preserves explicitly reported zero-valued token details.""" client = OpenAIChatCompletionClient(model="test-model", api_key="test-key") mock_usage = MagicMock() @@ -1237,19 +1251,23 @@ def test_parse_usage_includes_standard_and_legacy_mapped_token_details() -> None mock_usage.completion_tokens = 50 mock_usage.total_tokens = 150 mock_usage.completion_tokens_details = MagicMock() - mock_usage.completion_tokens_details.accepted_prediction_tokens = None - mock_usage.completion_tokens_details.audio_tokens = None + mock_usage.completion_tokens_details.accepted_prediction_tokens = 0 + mock_usage.completion_tokens_details.audio_tokens = 0 mock_usage.completion_tokens_details.reasoning_tokens = 0 - mock_usage.completion_tokens_details.rejected_prediction_tokens = None + mock_usage.completion_tokens_details.rejected_prediction_tokens = 0 mock_usage.prompt_tokens_details = MagicMock() - mock_usage.prompt_tokens_details.audio_tokens = None + mock_usage.prompt_tokens_details.audio_tokens = 0 mock_usage.prompt_tokens_details.cached_tokens = 0 details = client._parse_usage_from_openai(mock_usage) # type: ignore[arg-type] details_dict = cast("dict[str, Any]", details) + assert details_dict["completion/accepted_prediction_tokens"] == 0 + assert details_dict["completion/audio_tokens"] == 0 assert details_dict["completion/reasoning_tokens"] == 0 assert details["reasoning_output_token_count"] == 0 + assert details_dict["completion/rejected_prediction_tokens"] == 0 + assert details_dict["prompt/audio_tokens"] == 0 assert details_dict["prompt/cached_tokens"] == 0 assert details["cache_read_input_token_count"] == 0 diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client.py b/python/packages/openai/tests/openai/test_openai_embedding_client.py index 0325101e0e..629c47a79a 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -109,6 +109,21 @@ async def test_openai_get_embeddings(openai_unit_test_env: dict[str, str]) -> No assert result[0].dimensions == 3 +async def test_openai_get_embeddings_honors_response_indexes(openai_unit_test_env: dict[str, str]) -> None: + mock_response = _make_openai_response( + embeddings=[[0.1, 0.2], [0.3, 0.4]], + ) + mock_response.data.reverse() + client = OpenAIEmbeddingClient() + client.client = MagicMock() + client.client.embeddings = MagicMock() + client.client.embeddings.create = AsyncMock(return_value=mock_response) + + result = await client.get_embeddings(["first", "second"]) + + assert [embedding.vector for embedding in result] == [[0.1, 0.2], [0.3, 0.4]] + + async def test_embedding_request_marks_openai_feature( openai_unit_test_env: dict[str, str], ) -> None: