From a42e0817d70ef276eea59d8573a87b1ff83224e1 Mon Sep 17 00:00:00 2001 From: akushonkamen Date: Wed, 14 Oct 2026 00:05:13 +0800 Subject: [PATCH] fix(llm): use AsyncOpenAI client instead of sync client in executor Replace the blocking openai.OpenAI client with openai.AsyncOpenAI and await chat.completions.create directly, instead of running the sync client via run_in_executor on the default thread pool. This removes the thread-pool round trip and the risk of deadlocks when the default executor is saturated. Fixes #278 --- openevolve/llm/openai.py | 11 +++++------ tests/test_llm_ensemble.py | 4 ++-- tests/test_openai_non_streaming.py | 12 +++++++----- tests/test_reasoning_effort_config.py | 10 +++++----- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/openevolve/llm/openai.py b/openevolve/llm/openai.py index 8059b90dd..6b8fe197e 100644 --- a/openevolve/llm/openai.py +++ b/openevolve/llm/openai.py @@ -83,7 +83,7 @@ def __init__( # Set up API client (normal mode) # OpenAI client requires max_retries to be int, not None max_retries = self.retries if self.retries is not None else 0 - self.client = openai.OpenAI( + self.client = openai.AsyncOpenAI( api_key=self.api_key, base_url=self.api_base, timeout=self.timeout, @@ -217,11 +217,10 @@ async def _call_api(self, params: Dict[str, Any]) -> str: if self.client is None: raise RuntimeError("OpenAI client is not initialized (manual_mode enabled?)") - # Use asyncio to run the blocking API call in a thread pool - loop = asyncio.get_event_loop() - response = await loop.run_in_executor( - None, lambda: self.client.chat.completions.create(**params, stream=False) - ) + # Await the async client directly instead of running a blocking + # sync client in the default thread-pool executor, which can + # deadlock when the executor is saturated (see issue #278) + response = await self.client.chat.completions.create(**params, stream=False) if isinstance(response, str): # Some endpoints stream Server-Sent Events even when asked not to raise ValueError( diff --git a/tests/test_llm_ensemble.py b/tests/test_llm_ensemble.py index f6dfcaecb..45f798b00 100644 --- a/tests/test_llm_ensemble.py +++ b/tests/test_llm_ensemble.py @@ -65,7 +65,7 @@ def test_ensemble_initialization(self): class TestTokenUsageLogging(unittest.IsolatedAsyncioTestCase): async def test_openai_llm_token_usage_logging_and_propagation(self): - from unittest.mock import MagicMock + from unittest.mock import AsyncMock, MagicMock from openevolve.llm.openai import OpenAILLM cfg = LLMModelConfig( @@ -84,7 +84,7 @@ async def test_openai_llm_token_usage_logging_and_propagation(self): mock_response.usage.completion_tokens = 567 mock_response.usage.total_tokens = 1801 - llm.client.chat.completions.create = MagicMock(return_value=mock_response) + llm.client.chat.completions.create = AsyncMock(return_value=mock_response) with self.assertLogs("openevolve.llm.openai", level="INFO") as log_ctx: result = await llm.generate_with_context( diff --git a/tests/test_openai_non_streaming.py b/tests/test_openai_non_streaming.py index 244c2bb4b..8c5461e05 100644 --- a/tests/test_openai_non_streaming.py +++ b/tests/test_openai_non_streaming.py @@ -4,7 +4,7 @@ import asyncio import unittest -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from openevolve.llm.openai import OpenAILLM @@ -20,10 +20,10 @@ def _make_llm(): model_cfg.retries = 0 model_cfg.retry_delay = 0 model_cfg.api_base = "https://example.com/v1" - model_cfg.api_key = None # openai.OpenAI is patched + model_cfg.api_key = None # openai.AsyncOpenAI is patched model_cfg.random_seed = None model_cfg.reasoning_effort = None - with patch("openai.OpenAI"): + with patch("openai.AsyncOpenAI"): return OpenAILLM(model_cfg) @@ -33,7 +33,7 @@ def test_stream_false_is_sent(self): response = Mock() response.choices = [Mock()] response.choices[0].message.content = "ok" - llm.client.chat.completions.create.return_value = response + llm.client.chat.completions.create = AsyncMock(return_value=response) params = {"model": "test-model", "messages": [{"role": "user", "content": "hi"}]} self.assertEqual(asyncio.run(llm._call_api(params)), "ok") @@ -43,7 +43,9 @@ def test_stream_false_is_sent(self): def test_raw_sse_string_gives_clear_error(self): llm = _make_llm() - llm.client.chat.completions.create.return_value = 'data: {"object":"chat.completion.chunk"}' + llm.client.chat.completions.create = AsyncMock( + return_value='data: {"object":"chat.completion.chunk"}' + ) with self.assertRaisesRegex(ValueError, "streaming responses are not supported"): asyncio.run(llm._call_api({"model": "test-model", "messages": []})) diff --git a/tests/test_reasoning_effort_config.py b/tests/test_reasoning_effort_config.py index b9e8bb44d..2dae1db0a 100644 --- a/tests/test_reasoning_effort_config.py +++ b/tests/test_reasoning_effort_config.py @@ -129,12 +129,12 @@ def test_openai_llm_uses_reasoning_effort(self): model_cfg.retries = 3 model_cfg.retry_delay = 5 model_cfg.api_base = "https://api.openai.com/v1" - model_cfg.api_key = None # openai.OpenAI is patched + model_cfg.api_key = None # openai.AsyncOpenAI is patched model_cfg.random_seed = None model_cfg.reasoning_effort = "high" # Mock OpenAI client to avoid actual API calls - with unittest.mock.patch('openai.OpenAI'): + with unittest.mock.patch('openai.AsyncOpenAI'): llm = OpenAILLM(model_cfg) # Verify the reasoning_effort is stored @@ -152,18 +152,18 @@ def test_reasoning_effort_passed_to_api_params(self): model_cfg.retries = 3 model_cfg.retry_delay = 5 model_cfg.api_base = "https://api.openai.com/v1" - model_cfg.api_key = None # openai.OpenAI is patched + model_cfg.api_key = None # openai.AsyncOpenAI is patched model_cfg.random_seed = None model_cfg.reasoning_effort = "medium" - with unittest.mock.patch('openai.OpenAI'): + with unittest.mock.patch('openai.AsyncOpenAI'): llm = OpenAILLM(model_cfg) # Test the _call_api method directly with mocked client mock_response = Mock() mock_response.choices = [Mock()] mock_response.choices[0].message.content = "Test response" - llm.client.chat.completions.create.return_value = mock_response + llm.client.chat.completions.create = unittest.mock.AsyncMock(return_value=mock_response) # Test OpenAI reasoning model (gpt-oss-120b at openai.com should use reasoning logic) test_params = {