88from unittest .mock import Mock
99from unittest .mock import patch
1010
11- import httpx
12- import openai
1311import pytest
1412from trpc_agent_sdk .models import LlmRequest
1513from trpc_agent_sdk .models import OpenAIModel
@@ -242,23 +240,26 @@ def test_model_type_is_model(self):
242240
243241 assert model ._type == FilterType .MODEL
244242
245- def test_create_async_client_uses_custom_http_client_factory (self ):
246- """A custom http_client_factory is passed through to AsyncOpenAI."""
243+ def test_create_async_client_uses_custom_http_client_provider (self ):
244+ """A custom http_client_provider_factory is passed through to AsyncOpenAI."""
247245 shared_http_client = Mock ()
248- http_client_factory = Mock (return_value = shared_http_client )
246+ http_client_provider = Mock ()
247+ http_client_provider .create_http_client .return_value = shared_http_client
248+ http_client_provider_factory = Mock (return_value = http_client_provider )
249249 model = OpenAIModel (
250250 model_name = "gpt-4" ,
251251 api_key = "test_key" ,
252252 base_url = "https://custom.api.com" ,
253253 client_args = {"timeout" : 30 },
254- http_client_factory = http_client_factory ,
254+ http_client_provider_factory = http_client_provider_factory ,
255255 )
256256
257257 with patch ("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI" ) as mock_async_openai :
258258 client = model ._create_async_client ()
259259
260260 assert client is mock_async_openai .return_value
261- http_client_factory .assert_called_once_with ()
261+ http_client_provider_factory .assert_called_once_with ()
262+ http_client_provider .create_http_client .assert_called_once_with ()
262263 mock_async_openai .assert_called_once_with (
263264 api_key = "test_key" ,
264265 max_retries = 0 ,
@@ -268,39 +269,130 @@ def test_create_async_client_uses_custom_http_client_factory(self):
268269 http_client = shared_http_client ,
269270 )
270271
271- def test_create_async_client_default_factory_reuses_shared_http_client (self ):
272- """Default factory should reuse one shared httpx.AsyncClient across model calls."""
273- from trpc_agent_sdk .models import _openai_model
272+ def test_create_async_client_default_factory_reuses_loop_local_http_client (self ):
273+ """Default provider should reuse the same httpx.AsyncClient within one loop."""
274+ from trpc_agent_sdk .models import _httpx_client
275+ from trpc_agent_sdk .models import shared_http_client_provider_factory
274276
275- _openai_model ._shared_http_client = None
276277 shared_http_client = Mock ()
277- model = OpenAIModel (model_name = "gpt-4" , api_key = "test_key" )
278+ shared_http_client .is_closed = False
279+ model = OpenAIModel (model_name = "gpt-4" , api_key = "test_key" , http_client_provider_factory = shared_http_client_provider_factory )
278280
279281 try :
280- with patch ("trpc_agent_sdk.models._openai_model.httpx.AsyncClient" ,
282+ _httpx_client ._shared_http_clients .clear ()
283+ with patch ("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient" ,
281284 return_value = shared_http_client ) as mock_httpx_client :
282- with patch ("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI" ) as mock_async_openai :
283- model ._create_async_client ()
284- model ._create_async_client ()
285+ with patch ("trpc_agent_sdk.models._httpx_client._get_loop_key" , return_value = 1 ):
286+ with patch ("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI" ) as mock_async_openai :
287+ model ._create_async_client ()
288+ model ._create_async_client ()
285289 finally :
286- _openai_model . _shared_http_client = None
290+ _httpx_client . _shared_http_clients . clear ()
287291
288- mock_httpx_client .assert_called_once_with ()
292+ mock_httpx_client .assert_called_once_with (
293+ limits = _httpx_client ._DEFAULT_HTTP_CLIENT_LIMITS ,
294+ timeout = _httpx_client ._DEFAULT_HTTP_CLIENT_TIMEOUT ,
295+ follow_redirects = True ,
296+ )
289297 first_call_kwargs = mock_async_openai .call_args_list [0 ].kwargs
290298 second_call_kwargs = mock_async_openai .call_args_list [1 ].kwargs
291299 assert first_call_kwargs ["http_client" ] is shared_http_client
292300 assert second_call_kwargs ["http_client" ] is shared_http_client
293301
302+ def test_create_shared_http_client_rebuilds_closed_client (self ):
303+ """Closed cached clients should be replaced on the next factory call."""
304+ from trpc_agent_sdk .models import _httpx_client
305+
306+ closed_client = Mock ()
307+ closed_client .is_closed = True
308+ fresh_client = Mock ()
309+ fresh_client .is_closed = False
310+
311+ try :
312+ _httpx_client ._shared_http_clients .clear ()
313+ client_key = (1234 , 1 )
314+ _httpx_client ._shared_http_clients [client_key ] = closed_client
315+ with patch ("trpc_agent_sdk.models._httpx_client._get_client_key" , return_value = client_key ):
316+ with patch ("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient" ,
317+ return_value = fresh_client ) as mock_httpx_client :
318+ assert _httpx_client ._create_shared_http_client () is fresh_client
319+ finally :
320+ _httpx_client ._shared_http_clients .clear ()
321+
322+ mock_httpx_client .assert_called_once ()
323+
324+ def test_create_shared_http_client_does_not_reuse_across_loop_keys (self ):
325+ """Different event loops should get different default httpx clients."""
326+ from trpc_agent_sdk .models import _httpx_client
327+
328+ first_client = Mock ()
329+ first_client .is_closed = False
330+ second_client = Mock ()
331+ second_client .is_closed = False
332+
333+ try :
334+ _httpx_client ._shared_http_clients .clear ()
335+ with patch ("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient" ,
336+ side_effect = [first_client , second_client ]) as mock_httpx_client :
337+ with patch ("trpc_agent_sdk.models._httpx_client._get_client_key" , side_effect = [(1234 , 1 ), (1234 , 2 )]):
338+ assert _httpx_client ._create_shared_http_client () is first_client
339+ assert _httpx_client ._create_shared_http_client () is second_client
340+ finally :
341+ _httpx_client ._shared_http_clients .clear ()
342+
343+ assert mock_httpx_client .call_count == 2
344+
345+ def test_create_shared_http_client_does_not_reuse_across_process_keys (self ):
346+ """Different process keys should get different default httpx clients."""
347+ from trpc_agent_sdk .models import _httpx_client
348+
349+ parent_client = Mock ()
350+ parent_client .is_closed = False
351+ child_client = Mock ()
352+ child_client .is_closed = False
353+
354+ try :
355+ _httpx_client ._shared_http_clients .clear ()
356+ with patch ("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient" ,
357+ side_effect = [parent_client , child_client ]) as mock_httpx_client :
358+ with patch ("trpc_agent_sdk.models._httpx_client._get_client_key" ,
359+ side_effect = [(1234 , 1 ), (5678 , 1 )]):
360+ assert _httpx_client ._create_shared_http_client () is parent_client
361+ assert _httpx_client ._create_shared_http_client () is child_client
362+ finally :
363+ _httpx_client ._shared_http_clients .clear ()
364+
365+ assert mock_httpx_client .call_count == 2
366+
367+ def test_reset_shared_http_clients_after_fork_clears_cache_and_rebuilds_lock (self ):
368+ """Fork child reset should drop inherited clients and replace inherited locks."""
369+ from trpc_agent_sdk .models import _httpx_client
370+
371+ inherited_client = Mock ()
372+ old_lock = _httpx_client ._shared_http_clients_lock
373+
374+ try :
375+ _httpx_client ._shared_http_clients [(1234 , 1 )] = inherited_client
376+
377+ _httpx_client ._reset_shared_http_clients_after_fork ()
378+
379+ assert _httpx_client ._shared_http_clients == {}
380+ assert _httpx_client ._shared_http_clients_lock is not old_lock
381+ finally :
382+ _httpx_client ._shared_http_clients .clear ()
383+
294384 def test_create_async_client_overwrites_stale_client_args_http_client (self ):
295- """Factory owns http_client injection even if client_args already has one."""
385+ """Provider owns http_client injection even if client_args already has one."""
296386 stale_http_client = Mock ()
297387 fresh_http_client = Mock ()
298- http_client_factory = Mock (return_value = fresh_http_client )
388+ http_client_provider = Mock ()
389+ http_client_provider .create_http_client .return_value = fresh_http_client
390+ http_client_provider_factory = Mock (return_value = http_client_provider )
299391 model = OpenAIModel (
300392 model_name = "gpt-4" ,
301393 api_key = "test_key" ,
302394 client_args = {"http_client" : stale_http_client , "timeout" : 30 },
303- http_client_factory = http_client_factory ,
395+ http_client_provider_factory = http_client_provider_factory ,
304396 )
305397
306398 with patch ("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI" ) as mock_async_openai :
@@ -354,15 +446,19 @@ async def test_generate_async_simple_text_response(self):
354446
355447 @pytest .mark .asyncio
356448 async def test_generate_async_validation_failure (self ):
357- """Test generate_async converts validation failures to error responses ."""
449+ """Test generate_async returns an error response on invalid request ."""
358450 model = OpenAIModel (model_name = "gpt-4" , api_key = "test_key" )
359451
452+ # Empty contents
360453 request = LlmRequest (contents = [], config = None , tools_dict = {})
361454
362- responses = [response async for response in model .generate_async (request , stream = False )]
455+ responses = []
456+ async for response in model .generate_async (request , stream = False ):
457+ responses .append (response )
458+
363459 assert len (responses ) == 1
364460 assert responses [0 ].error_code == "API_ERROR"
365- assert "At least one content is required" in ( responses [0 ].error_message or "" )
461+ assert "At least one content is required" in responses [0 ].error_message
366462
367463 @pytest .mark .asyncio
368464 async def test_generate_async_with_config_parameters (self ):
@@ -849,47 +945,3 @@ def test_null_prompt_tokens_details_does_not_crash(self):
849945 }
850946 meta = OpenAIModel ._build_usage_metadata (usage_data )
851947 assert meta .cache_read_input_tokens is None
852-
853-
854- class _RetryTestError (Exception ):
855-
856- def __init__ (self , status_code = None , headers = None ):
857- super ().__init__ (f"status { status_code } " if status_code is not None else "retry test" )
858- if status_code is not None :
859- self .status_code = status_code
860- if headers is not None :
861- self .response = type ("Resp" , (), {"headers" : headers })()
862-
863-
864- class TestOpenAIModelRetryHooks :
865-
866- def _model (self ):
867- return OpenAIModel (model_name = "gpt-4" , api_key = "test_key" )
868-
869- def test_x_should_retry_header_has_priority (self ):
870- model = self ._model ()
871- assert model ._get_model_retry_info (_RetryTestError (400 , {"x-should-retry" : "true" })).should_retry is True
872- assert model ._get_model_retry_info (_RetryTestError (500 , {"x-should-retry" : "false" })).should_retry is False
873-
874- @pytest .mark .parametrize ("status_code" , [408 , 409 , 429 , 500 , 503 ])
875- def test_retryable_status_codes (self , status_code ):
876- assert self ._model ()._get_model_retry_info (_RetryTestError (status_code )).should_retry is True
877-
878- @pytest .mark .parametrize ("status_code" , [400 , 401 , 403 , 404 , 499 ])
879- def test_non_retryable_status_codes (self , status_code ):
880- assert self ._model ()._get_model_retry_info (_RetryTestError (status_code )).should_retry is False
881-
882- def test_timeout_exception_retried (self ):
883- request = httpx .Request ("GET" , "https://example.com" )
884- assert self ._model ()._get_model_retry_info (httpx .TimeoutException ("timeout" , request = request )).should_retry is True
885-
886- def test_openai_error_not_retried_without_status_decision (self ):
887- assert self ._model ()._get_model_retry_info (openai .OpenAIError ("boom" )).should_retry is False
888-
889- def test_other_exception_retried (self ):
890- assert self ._model ()._get_model_retry_info (ValueError ("boom" )).should_retry is True
891-
892- def test_retry_after_extracted_from_headers (self ):
893- info = self ._model ()._get_model_retry_info (_RetryTestError (429 , {"retry-after" : "3" }))
894- assert info .should_retry is True
895- assert info .retry_after == 3.0
0 commit comments