You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

test_responses_api_client.py 20 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. """
  2. Tests for OpenAI Responses API client implementation.
  3. The Responses API is designed specifically for GPT-5 and provides:
  4. - Chain-of-thought preservation between conversation turns
  5. - Reduced reasoning token generation through context reuse
  6. - Improved cache hit rates and lower latency
  7. - Better integration with GPT-5 reasoning features
  8. These tests validate the Responses API client implementation,
  9. parameter handling, and integration with AutoGen frameworks.
  10. """
  11. from typing import Any, Dict, cast
  12. from unittest.mock import AsyncMock, patch
  13. import pytest
  14. from autogen_core import CancellationToken
  15. from autogen_core.models import CreateResult
  16. from autogen_ext.models.openai import (
  17. AzureOpenAIResponsesAPIClient,
  18. OpenAIResponsesAPIClient,
  19. )
  20. from autogen_ext.models.openai._responses_client import (
  21. ResponsesAPICreateParams,
  22. )
  23. from test_gpt5_features import TestCodeExecutorTool
  24. class TestResponsesAPIClientInitialization:
  25. """Test Responses API client initialization and configuration."""
  26. def test_openai_responses_client_creation(self) -> None:
  27. """Test OpenAI Responses API client can be created."""
  28. with patch("autogen_ext.models.openai._responses_client._openai_client_from_config") as mock:
  29. mock.return_value = AsyncMock()
  30. client = OpenAIResponsesAPIClient(model="gpt-5", api_key="test-key")
  31. # Access through public info() for type safety
  32. assert client.info()["family"] == "GPT_5"
  33. def test_azure_responses_client_creation(self) -> None:
  34. """Test Azure OpenAI Responses API client can be created."""
  35. with patch("autogen_ext.models.openai._responses_client._azure_openai_client_from_config") as mock:
  36. mock.return_value = AsyncMock()
  37. client = AzureOpenAIResponsesAPIClient(
  38. model="gpt-5",
  39. azure_endpoint="https://test.openai.azure.com/",
  40. azure_deployment="gpt-5-deployment",
  41. api_version="2024-06-01",
  42. api_key="test-key",
  43. )
  44. assert client.info()["family"] == "GPT_5"
  45. def test_invalid_model_raises_error(self) -> None:
  46. """Test that invalid model names raise appropriate errors."""
  47. with patch("autogen_ext.models.openai._responses_client._openai_client_from_config") as mock:
  48. mock.return_value = AsyncMock()
  49. with pytest.raises(ValueError, match="model_info is required"):
  50. OpenAIResponsesAPIClient(model="invalid-model", api_key="test-key")
  51. class TestResponsesAPIParameterHandling:
  52. """Test Responses API specific parameter handling."""
  53. @pytest.fixture
  54. def mock_openai_client(self) -> Any:
  55. with patch("autogen_ext.models.openai._responses_client._openai_client_from_config") as mock:
  56. mock_client = AsyncMock()
  57. mock_client.responses.create = AsyncMock()
  58. mock.return_value = mock_client
  59. yield mock_client
  60. @pytest.fixture
  61. def client(self, mock_openai_client: Any) -> OpenAIResponsesAPIClient:
  62. return OpenAIResponsesAPIClient(model="gpt-5", api_key="test-key")
  63. def test_process_create_args_basic(self, client: OpenAIResponsesAPIClient) -> None:
  64. """Test basic parameter processing for Responses API."""
  65. params = client._OpenAIResponsesAPIClient__process_create_args( # type: ignore[attr-defined]
  66. input="Test input",
  67. tools=[],
  68. tool_choice="auto",
  69. extra_create_args={},
  70. reasoning_effort="medium",
  71. verbosity="high",
  72. preambles=True,
  73. )
  74. assert isinstance(params, ResponsesAPICreateParams)
  75. assert params.input == "Test input"
  76. assert params.create_args["input"] == "Test input"
  77. assert params.create_args["reasoning"]["effort"] == "medium"
  78. assert params.create_args["text"]["verbosity"] == "high"
  79. assert params.create_args["preambles"] is True
  80. def test_process_create_args_with_cot_preservation(self, client: OpenAIResponsesAPIClient) -> None:
  81. """Test chain-of-thought preservation parameters."""
  82. params = client._OpenAIResponsesAPIClient__process_create_args( # type: ignore[attr-defined]
  83. input="Follow-up question",
  84. tools=[],
  85. tool_choice="auto",
  86. extra_create_args={},
  87. previous_response_id="resp-123",
  88. reasoning_items=[{"type": "reasoning", "content": "Previous reasoning"}],
  89. )
  90. # mypy/pyright: create_args is a dict[str, Any]
  91. create_args: Dict[str, Any] = params.create_args
  92. assert create_args.get("previous_response_id") == "resp-123"
  93. assert create_args.get("reasoning_items") == [{"type": "reasoning", "content": "Previous reasoning"}]
  94. def test_invalid_extra_args_rejected(self, client: OpenAIResponsesAPIClient) -> None:
  95. """Test that invalid extra arguments are rejected."""
  96. with pytest.raises(ValueError, match="Extra create args are invalid for Responses API"):
  97. client._OpenAIResponsesAPIClient__process_create_args( # type: ignore[attr-defined]
  98. input="Test",
  99. tools=[],
  100. tool_choice="auto",
  101. extra_create_args={"invalid_param": "value"}, # Not allowed in Responses API
  102. )
  103. def test_default_reasoning_effort(self, client: OpenAIResponsesAPIClient) -> None:
  104. """Test default reasoning effort is set when not specified."""
  105. params = client._OpenAIResponsesAPIClient__process_create_args( # type: ignore[attr-defined]
  106. input="Test input", tools=[], tool_choice="auto", extra_create_args={}
  107. )
  108. # Should default to medium reasoning effort
  109. create_args: Dict[str, Any] = params.create_args
  110. reasoning: Dict[str, Any] = cast(Dict[str, Any], create_args.get("reasoning", {}))
  111. assert reasoning.get("effort") == "medium"
  112. class TestResponsesAPICallHandling:
  113. """Test actual API call handling and response processing."""
  114. @pytest.fixture
  115. def mock_openai_client(self) -> Any:
  116. with patch("autogen_ext.models.openai._responses_client._openai_client_from_config") as mock:
  117. mock_client = AsyncMock()
  118. mock_client.responses.create = AsyncMock()
  119. mock.return_value = mock_client
  120. yield mock_client
  121. @pytest.fixture
  122. def client(self, mock_openai_client: Any) -> OpenAIResponsesAPIClient:
  123. return OpenAIResponsesAPIClient(model="gpt-5", api_key="test-key")
  124. async def test_basic_text_response(self, client: OpenAIResponsesAPIClient, mock_openai_client: Any) -> None:
  125. """Test processing of basic text response."""
  126. mock_response = {
  127. "id": "resp-123",
  128. "choices": [{"message": {"content": "This is a test response"}, "finish_reason": "stop"}],
  129. "usage": {"prompt_tokens": 15, "completion_tokens": 25},
  130. }
  131. mock_openai_client.responses.create.return_value = mock_response
  132. result = await client.create(input="Test question")
  133. assert isinstance(result, CreateResult)
  134. assert result.content == "This is a test response"
  135. assert result.finish_reason == "stop"
  136. assert result.usage.prompt_tokens == 15
  137. assert result.usage.completion_tokens == 25
  138. assert hasattr(result, "response_id")
  139. assert result.response_id == "resp-123" # type: ignore
  140. async def test_response_with_reasoning(self, client: OpenAIResponsesAPIClient, mock_openai_client: Any) -> None:
  141. """Test processing response with reasoning items."""
  142. mock_response = {
  143. "id": "resp-124",
  144. "choices": [{"message": {"content": "Final answer after reasoning"}, "finish_reason": "stop"}],
  145. "reasoning_items": [
  146. {"type": "reasoning", "content": "First, I need to consider..."},
  147. {"type": "reasoning", "content": "Then, I should analyze..."},
  148. {"type": "reasoning", "content": "Finally, the conclusion is..."},
  149. ],
  150. "usage": {"prompt_tokens": 30, "completion_tokens": 50},
  151. }
  152. mock_openai_client.responses.create.return_value = mock_response
  153. result = await client.create(input="Complex reasoning question", reasoning_effort="high")
  154. assert result.content == "Final answer after reasoning"
  155. assert result.thought is not None
  156. assert "First, I need to consider..." in result.thought
  157. assert "Then, I should analyze..." in result.thought
  158. assert "Finally, the conclusion is..." in result.thought
  159. async def test_custom_tool_call_response(self, client: OpenAIResponsesAPIClient, mock_openai_client: Any) -> None:
  160. """Test processing response with custom tool calls."""
  161. from test_gpt5_features import TestCodeExecutorTool
  162. code_tool = TestCodeExecutorTool()
  163. mock_response = {
  164. "id": "resp-125",
  165. "choices": [
  166. {
  167. "message": {
  168. "content": "I'll execute this Python code for you.",
  169. "tool_calls": [
  170. {
  171. "id": "call-789",
  172. "custom": {
  173. "name": "code_exec",
  174. "input": "print('Hello from GPT-5!')\nresult = 2 + 2\nprint(f'2 + 2 = {result}')",
  175. },
  176. }
  177. ],
  178. },
  179. "finish_reason": "tool_calls",
  180. }
  181. ],
  182. "usage": {"prompt_tokens": 25, "completion_tokens": 35},
  183. }
  184. mock_openai_client.responses.create.return_value = mock_response
  185. result = await client.create(input="Run this Python code to do basic math", tools=[code_tool], preambles=True)
  186. assert isinstance(result.content, list)
  187. assert len(result.content) == 1
  188. tool_call = result.content[0]
  189. assert tool_call.name == "code_exec"
  190. assert "print('Hello from GPT-5!')" in tool_call.arguments
  191. assert result.thought == "I'll execute this Python code for you."
  192. assert str(result.finish_reason) == "tool_calls"
  193. async def test_cot_preservation_call(self, client: OpenAIResponsesAPIClient, mock_openai_client: Any) -> None:
  194. """Test call with chain-of-thought preservation."""
  195. # First call
  196. mock_response1 = {
  197. "id": "resp-100",
  198. "choices": [{"message": {"content": "Initial response"}, "finish_reason": "stop"}],
  199. "usage": {"prompt_tokens": 20, "completion_tokens": 30},
  200. "reasoning_items": [{"type": "reasoning", "content": "Initial reasoning"}],
  201. }
  202. mock_openai_client.responses.create.return_value = mock_response1
  203. result1 = await client.create(input="First question", reasoning_effort="high")
  204. # Second call with preserved context
  205. mock_response2 = {
  206. "id": "resp-101",
  207. "choices": [{"message": {"content": "Follow-up response"}, "finish_reason": "stop"}],
  208. "usage": {"prompt_tokens": 10, "completion_tokens": 20}, # Lower tokens due to context reuse
  209. }
  210. mock_openai_client.responses.create.return_value = mock_response2
  211. result2 = await client.create(
  212. input="Follow-up question",
  213. previous_response_id=result1.response_id, # type: ignore
  214. reasoning_effort="low",
  215. )
  216. # Verify parameters were passed correctly
  217. call_kwargs = mock_openai_client.responses.create.call_args[1]
  218. assert call_kwargs["previous_response_id"] == "resp-100"
  219. assert call_kwargs["reasoning"]["effort"] == "low"
  220. # Verify lower token usage due to context reuse
  221. assert result2.usage.prompt_tokens < result1.usage.prompt_tokens
  222. class TestResponsesAPIErrorHandling:
  223. """Test error handling in Responses API client."""
  224. @pytest.fixture
  225. def mock_openai_client(self) -> Any:
  226. with patch("autogen_ext.models.openai._responses_client._openai_client_from_config") as mock:
  227. mock_client = AsyncMock()
  228. mock_client.responses.create = AsyncMock()
  229. mock.return_value = mock_client
  230. yield mock_client
  231. @pytest.fixture
  232. def client(self, mock_openai_client: Any) -> OpenAIResponsesAPIClient:
  233. return OpenAIResponsesAPIClient(model="gpt-5", api_key="test-key")
  234. async def test_api_error_propagation(self, client: OpenAIResponsesAPIClient, mock_openai_client: Any) -> None:
  235. """Test that API errors are properly propagated."""
  236. from openai import APIError
  237. # Instantiate with minimal required args for latest SDK
  238. mock_openai_client.responses.create.side_effect = APIError(message="Test API error") # type: ignore[call-arg]
  239. with pytest.raises(APIError, match="Test API error"):
  240. await client.create(input="Test input")
  241. async def test_cancellation_token_support(self, client: OpenAIResponsesAPIClient, mock_openai_client: Any) -> None:
  242. """Test cancellation token is properly handled."""
  243. cancellation_token = CancellationToken()
  244. # Mock a successful response
  245. mock_response = {
  246. "id": "resp-999",
  247. "choices": [{"message": {"content": "Response"}, "finish_reason": "stop"}],
  248. "usage": {"prompt_tokens": 5, "completion_tokens": 10},
  249. }
  250. mock_openai_client.responses.create.return_value = mock_response
  251. result = await client.create(input="Test with cancellation", cancellation_token=cancellation_token)
  252. assert result.content == "Response"
  253. # Verify cancellation token was linked to the future
  254. # (This is tested implicitly by successful completion)
  255. async def test_malformed_response_handling(self, client: OpenAIResponsesAPIClient, mock_openai_client: Any) -> None:
  256. """Test handling of malformed API responses."""
  257. # Response missing required fields
  258. mock_response = {
  259. "id": "resp-bad"
  260. # Missing choices, usage, etc.
  261. }
  262. mock_openai_client.responses.create.return_value = mock_response
  263. result = await client.create(input="Test malformed response")
  264. # Should handle gracefully with defaults
  265. assert result.content == ""
  266. assert result.usage.prompt_tokens == 0
  267. assert result.usage.completion_tokens == 0
  268. class TestResponsesAPIIntegration:
  269. """Test integration scenarios for Responses API."""
  270. @pytest.fixture
  271. def mock_openai_client(self) -> Any:
  272. with patch("autogen_ext.models.openai._responses_client._openai_client_from_config") as mock:
  273. mock_client = AsyncMock()
  274. mock_client.responses.create = AsyncMock()
  275. mock.return_value = mock_client
  276. yield mock_client
  277. @pytest.fixture
  278. def client(self, mock_openai_client: Any) -> OpenAIResponsesAPIClient:
  279. return OpenAIResponsesAPIClient(model="gpt-5", api_key="test-key")
  280. async def test_multi_turn_conversation_simulation(
  281. self, client: OpenAIResponsesAPIClient, mock_openai_client: Any
  282. ) -> None:
  283. """Simulate a realistic multi-turn conversation with GPT-5."""
  284. # Turn 1: Initial complex question
  285. mock_openai_client.responses.create.return_value = {
  286. "id": "resp-001",
  287. "choices": [
  288. {"message": {"content": "Let me break down quantum computing fundamentals..."}, "finish_reason": "stop"}
  289. ],
  290. "reasoning_items": [
  291. {"type": "reasoning", "content": "This is a complex topic requiring careful explanation..."}
  292. ],
  293. "usage": {"prompt_tokens": 50, "completion_tokens": 200},
  294. }
  295. result1 = await client.create(
  296. input="Explain quantum computing to someone with a physics background",
  297. reasoning_effort="high",
  298. verbosity="high",
  299. )
  300. # Turn 2: Follow-up question with context reuse
  301. mock_openai_client.responses.create.return_value = {
  302. "id": "resp-002",
  303. "choices": [
  304. {
  305. "message": {"content": "Building on quantum fundamentals, quantum algorithms..."},
  306. "finish_reason": "stop",
  307. }
  308. ],
  309. "usage": {"prompt_tokens": 30, "completion_tokens": 150}, # Lower due to context
  310. }
  311. result2 = await client.create(
  312. input="How do quantum algorithms leverage these principles?",
  313. previous_response_id=result1.response_id, # type: ignore
  314. reasoning_effort="medium", # Less reasoning needed due to context
  315. )
  316. # Turn 3: Specific implementation request
  317. mock_openai_client.responses.create.return_value = {
  318. "id": "resp-003",
  319. "choices": [
  320. {
  321. "message": {
  322. "content": "I'll provide a simple quantum algorithm implementation.",
  323. "tool_calls": [
  324. {
  325. "id": "call-001",
  326. "custom": {
  327. "name": "code_exec",
  328. "input": "# Simple quantum circuit\nfrom qiskit import QuantumCircuit\nqc = QuantumCircuit(2)\nqc.h(0)\nqc.cx(0, 1)\nprint(qc)",
  329. },
  330. }
  331. ],
  332. },
  333. "finish_reason": "tool_calls",
  334. }
  335. ],
  336. "usage": {"prompt_tokens": 25, "completion_tokens": 100},
  337. }
  338. code_tool = TestCodeExecutorTool()
  339. result3 = await client.create(
  340. input="Show me a simple quantum circuit implementation",
  341. previous_response_id=result2.response_id, # type: ignore
  342. tools=[code_tool],
  343. reasoning_effort="minimal", # Very little reasoning needed
  344. preambles=True,
  345. )
  346. # Verify the conversation flow
  347. assert "quantum computing fundamentals" in result1.content
  348. assert result1.thought is not None
  349. assert "quantum algorithms" in result2.content
  350. assert result2.usage.prompt_tokens < result1.usage.prompt_tokens
  351. assert isinstance(result3.content, list)
  352. assert result3.content[0].name == "code_exec"
  353. assert "QuantumCircuit" in result3.content[0].arguments
  354. assert result3.thought == "I'll provide a simple quantum algorithm implementation."
  355. async def test_usage_tracking(self, client: OpenAIResponsesAPIClient, mock_openai_client: Any) -> None:
  356. """Test token usage tracking across multiple calls."""
  357. # Multiple API calls with different usage
  358. call_responses = [
  359. {
  360. "id": "r1",
  361. "choices": [{"message": {"content": "Response 1"}, "finish_reason": "stop"}],
  362. "usage": {"prompt_tokens": 10, "completion_tokens": 20},
  363. },
  364. {
  365. "id": "r2",
  366. "choices": [{"message": {"content": "Response 2"}, "finish_reason": "stop"}],
  367. "usage": {"prompt_tokens": 15, "completion_tokens": 25},
  368. },
  369. {
  370. "id": "r3",
  371. "choices": [{"message": {"content": "Response 3"}, "finish_reason": "stop"}],
  372. "usage": {"prompt_tokens": 5, "completion_tokens": 15},
  373. },
  374. ]
  375. for i, response in enumerate(call_responses):
  376. mock_openai_client.responses.create.return_value = response
  377. await client.create(input=f"Test input {i+1}")
  378. # Check cumulative usage
  379. total_usage = client.total_usage()
  380. actual_usage = client.actual_usage()
  381. assert total_usage.prompt_tokens == 30 # 10 + 15 + 5
  382. assert total_usage.completion_tokens == 60 # 20 + 25 + 15
  383. assert actual_usage.prompt_tokens == 30
  384. assert actual_usage.completion_tokens == 60
  385. if __name__ == "__main__":
  386. pytest.main([__file__, "-v"])