Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 14 additions & 1 deletion python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions python/packages/openai/tests/openai/test_openai_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1228,28 +1242,32 @@ 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()
mock_usage.prompt_tokens = 100
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading