diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 59f330697a..81fe1f3b73 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -5,7 +5,7 @@ import uuid from collections.abc import AsyncIterable from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast from agent_framework import ( @@ -269,7 +269,7 @@ def _convert_workflow_event_to_agent_update( author_name=self.name, response_id=response_id, message_id=str(uuid.uuid4()), - created_at=datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), ) case _: # Ignore workflow-internal events diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 9acf4b227d..73605fadef 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -3,7 +3,7 @@ import json import sys from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, MutableSequence, Sequence -from datetime import datetime +from datetime import datetime, timezone from itertools import chain from typing import Any, TypeVar @@ -214,7 +214,7 @@ def _create_chat_response(self, response: ChatCompletion, chat_options: ChatOpti messages.append(ChatMessage(role="assistant", contents=contents)) return ChatResponse( response_id=response.id, - created_at=datetime.fromtimestamp(response.created).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + created_at=datetime.fromtimestamp(response.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), usage_details=self._usage_details_from_openai(response.usage) if response.usage else None, messages=messages, model_id=response.model, @@ -249,7 +249,7 @@ def _create_chat_response_update( if text_content := self._parse_text_from_choice(choice): contents.append(text_content) return ChatResponseUpdate( - created_at=datetime.fromtimestamp(chunk.created).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), contents=contents, role=Role.ASSISTANT, model_id=chunk.model, diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 6d4fce7bb2..d1857fb4fe 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, MutableSequence, Sequence -from datetime import datetime +from datetime import datetime, timezone from itertools import chain from typing import Any, TypeVar @@ -815,7 +815,9 @@ def _create_response_content( response_message = ChatMessage(role="assistant", contents=contents) args: dict[str, Any] = { "response_id": response.id, - "created_at": datetime.fromtimestamp(response.created_at).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "created_at": datetime.fromtimestamp(response.created_at, tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ), "messages": response_message, "model_id": response.model, "additional_properties": metadata, diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 38a3fe414e..92f82b9969 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -2,6 +2,7 @@ import base64 from collections.abc import AsyncIterable +from datetime import datetime, timezone from typing import Any import pytest @@ -935,6 +936,52 @@ def test_agent_run_response_update_str_method(text_content: TextContent) -> None assert str(update) == "Test content" +def test_agent_run_response_update_created_at() -> None: + """Test that AgentRunResponseUpdate properly handles created_at timestamps.""" + # Test with a properly formatted UTC timestamp + utc_timestamp = "2024-12-01T00:31:30.000000Z" + update = AgentRunResponseUpdate( + contents=[TextContent(text="test")], + role=Role.ASSISTANT, + created_at=utc_timestamp, + ) + assert update.created_at == utc_timestamp + assert update.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Verify that we can generate a proper UTC timestamp + now_utc = datetime.now(tz=timezone.utc) + formatted_utc = now_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + update_with_now = AgentRunResponseUpdate( + contents=[TextContent(text="test")], + role=Role.ASSISTANT, + created_at=formatted_utc, + ) + assert update_with_now.created_at == formatted_utc + assert update_with_now.created_at.endswith("Z") + + +def test_agent_run_response_created_at() -> None: + """Test that AgentRunResponse properly handles created_at timestamps.""" + # Test with a properly formatted UTC timestamp + utc_timestamp = "2024-12-01T00:31:30.000000Z" + response = AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text="Hello")], + created_at=utc_timestamp, + ) + assert response.created_at == utc_timestamp + assert response.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Verify that we can generate a proper UTC timestamp + now_utc = datetime.now(tz=timezone.utc) + formatted_utc = now_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + response_with_now = AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text="Hello")], + created_at=formatted_utc, + ) + assert response_with_now.created_at == formatted_utc + assert response_with_now.created_at.endswith("Z") + + # region ErrorContent diff --git a/python/packages/core/tests/openai/test_openai_chat_client_base.py b/python/packages/core/tests/openai/test_openai_chat_client_base.py index 86d41d9595..b146bad613 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client_base.py +++ b/python/packages/core/tests/openai/test_openai_chat_client_base.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from copy import deepcopy +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -370,3 +371,75 @@ async def test_get_streaming_no_stream( messages=chat_history, ) ] + + +# region UTC Timestamp Tests + + +def test_chat_response_created_at_uses_utc(openai_unit_test_env: dict[str, str]): + """Test that ChatResponse.created_at uses UTC timestamp, not local time. + + This is a regression test for the issue where created_at was using local time + but labeling it as UTC (with 'Z' suffix). + """ + from agent_framework import ChatOptions + + # Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC) + # This ensures we test that the timestamp is actually converted to UTC + utc_timestamp = 1733011890 + + mock_response = ChatCompletion( + id="test_id", + choices=[ + Choice(index=0, message=ChatCompletionMessage(content="test", role="assistant"), finish_reason="stop") + ], + created=utc_timestamp, + model="test", + object="chat.completion", + ) + + client = OpenAIChatClient() + response = client._create_chat_response(mock_response, ChatOptions()) + + # Verify that created_at is correctly formatted as UTC + assert response.created_at is not None + assert response.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Parse the timestamp and verify it matches UTC time + expected_utc_time = datetime.fromtimestamp(utc_timestamp, tz=timezone.utc) + expected_formatted = expected_utc_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + assert response.created_at == expected_formatted, ( + f"Expected UTC timestamp {expected_formatted}, got {response.created_at}" + ) + + +def test_chat_response_update_created_at_uses_utc(openai_unit_test_env: dict[str, str]): + """Test that ChatResponseUpdate.created_at uses UTC timestamp, not local time. + + This is a regression test for the issue where created_at was using local time + but labeling it as UTC (with 'Z' suffix). + """ + # Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC) + utc_timestamp = 1733011890 + + mock_chunk = ChatCompletionChunk( + id="test_id", + choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")], + created=utc_timestamp, + model="test", + object="chat.completion.chunk", + ) + + client = OpenAIChatClient() + response_update = client._create_chat_response_update(mock_chunk) + + # Verify that created_at is correctly formatted as UTC + assert response_update.created_at is not None + assert response_update.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Parse the timestamp and verify it matches UTC time + expected_utc_time = datetime.fromtimestamp(utc_timestamp, tz=timezone.utc) + expected_formatted = expected_utc_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + assert response_update.created_at == expected_formatted, ( + f"Expected UTC timestamp {expected_formatted}, got {response_update.created_at}" + ) diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index c4d824d31d..f2f9004d55 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -3,6 +3,7 @@ import asyncio import base64 import os +from datetime import datetime, timezone from typing import Annotated from unittest.mock import MagicMock, patch @@ -684,6 +685,51 @@ def test_create_response_content_with_mcp_approval_request() -> None: assert req.function_call.additional_properties["server_label"] == "My_MCP" +def test_responses_client_created_at_uses_utc(openai_unit_test_env: dict[str, str]) -> None: + """Test that ChatResponse from responses client uses UTC timestamp. + + This is a regression test for the issue where created_at was using local time + but labeling it as UTC (with 'Z' suffix). + """ + client = OpenAIResponsesClient() + + # Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC) + utc_timestamp = 1733011890 + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = utc_timestamp + + mock_message_content = MagicMock() + mock_message_content.type = "output_text" + mock_message_content.text = "Test response" + mock_message_content.annotations = None + + mock_message_item = MagicMock() + mock_message_item.type = "message" + mock_message_item.content = [mock_message_content] + + mock_response.output = [mock_message_item] + + with patch.object(client, "_get_metadata_from_response", return_value={}): + response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore + + # Verify that created_at is correctly formatted as UTC + assert response.created_at is not None + assert response.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Parse the timestamp and verify it matches UTC time + expected_utc_time = datetime.fromtimestamp(utc_timestamp, tz=timezone.utc) + expected_formatted = expected_utc_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + assert response.created_at == expected_formatted, ( + f"Expected UTC timestamp {expected_formatted}, got {response.created_at}" + ) + + def test_tools_to_response_tools_with_raw_image_generation() -> None: """Test that raw image_generation tool dict is handled correctly with parameter mapping.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")