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,129 @@ 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
274275
275- _openai_model ._shared_http_client = None
276276 shared_http_client = Mock ()
277+ shared_http_client .is_closed = False
277278 model = OpenAIModel (model_name = "gpt-4" , api_key = "test_key" )
278279
279280 try :
280- with patch ("trpc_agent_sdk.models._openai_model.httpx.AsyncClient" ,
281+ _httpx_client ._shared_http_clients .clear ()
282+ with patch ("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient" ,
281283 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 ()
284+ with patch ("trpc_agent_sdk.models._httpx_client._get_loop_key" , return_value = 1 ):
285+ with patch ("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI" ) as mock_async_openai :
286+ model ._create_async_client ()
287+ model ._create_async_client ()
285288 finally :
286- _openai_model . _shared_http_client = None
289+ _httpx_client . _shared_http_clients . clear ()
287290
288- mock_httpx_client .assert_called_once_with ()
291+ mock_httpx_client .assert_called_once_with (
292+ limits = _httpx_client ._DEFAULT_HTTP_CLIENT_LIMITS ,
293+ timeout = _httpx_client ._DEFAULT_HTTP_CLIENT_TIMEOUT ,
294+ follow_redirects = True ,
295+ )
289296 first_call_kwargs = mock_async_openai .call_args_list [0 ].kwargs
290297 second_call_kwargs = mock_async_openai .call_args_list [1 ].kwargs
291298 assert first_call_kwargs ["http_client" ] is shared_http_client
292299 assert second_call_kwargs ["http_client" ] is shared_http_client
293300
301+ def test_create_shared_http_client_rebuilds_closed_client (self ):
302+ """Closed cached clients should be replaced on the next factory call."""
303+ from trpc_agent_sdk .models import _httpx_client
304+
305+ closed_client = Mock ()
306+ closed_client .is_closed = True
307+ fresh_client = Mock ()
308+ fresh_client .is_closed = False
309+
310+ try :
311+ _httpx_client ._shared_http_clients .clear ()
312+ client_key = (1234 , 1 )
313+ _httpx_client ._shared_http_clients [client_key ] = closed_client
314+ with patch ("trpc_agent_sdk.models._httpx_client._get_client_key" , return_value = client_key ):
315+ with patch ("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient" ,
316+ return_value = fresh_client ) as mock_httpx_client :
317+ assert _httpx_client ._create_shared_http_client () is fresh_client
318+ finally :
319+ _httpx_client ._shared_http_clients .clear ()
320+
321+ mock_httpx_client .assert_called_once ()
322+
323+ def test_create_shared_http_client_does_not_reuse_across_loop_keys (self ):
324+ """Different event loops should get different default httpx clients."""
325+ from trpc_agent_sdk .models import _httpx_client
326+
327+ first_client = Mock ()
328+ first_client .is_closed = False
329+ second_client = Mock ()
330+ second_client .is_closed = False
331+
332+ try :
333+ _httpx_client ._shared_http_clients .clear ()
334+ with patch ("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient" ,
335+ side_effect = [first_client , second_client ]) as mock_httpx_client :
336+ with patch ("trpc_agent_sdk.models._httpx_client._get_client_key" , side_effect = [(1234 , 1 ), (1234 , 2 )]):
337+ assert _httpx_client ._create_shared_http_client () is first_client
338+ assert _httpx_client ._create_shared_http_client () is second_client
339+ finally :
340+ _httpx_client ._shared_http_clients .clear ()
341+
342+ assert mock_httpx_client .call_count == 2
343+
344+ def test_create_shared_http_client_does_not_reuse_across_process_keys (self ):
345+ """Different process keys should get different default httpx clients."""
346+ from trpc_agent_sdk .models import _httpx_client
347+
348+ parent_client = Mock ()
349+ parent_client .is_closed = False
350+ child_client = Mock ()
351+ child_client .is_closed = False
352+
353+ try :
354+ _httpx_client ._shared_http_clients .clear ()
355+ with patch ("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient" ,
356+ side_effect = [parent_client , child_client ]) as mock_httpx_client :
357+ with patch ("trpc_agent_sdk.models._httpx_client._get_client_key" ,
358+ side_effect = [(1234 , 1 ), (5678 , 1 )]):
359+ assert _httpx_client ._create_shared_http_client () is parent_client
360+ assert _httpx_client ._create_shared_http_client () is child_client
361+ finally :
362+ _httpx_client ._shared_http_clients .clear ()
363+
364+ assert mock_httpx_client .call_count == 2
365+
366+ def test_reset_shared_http_clients_after_fork_clears_cache_and_rebuilds_lock (self ):
367+ """Fork child reset should drop inherited clients and replace inherited locks."""
368+ from trpc_agent_sdk .models import _httpx_client
369+
370+ inherited_client = Mock ()
371+ old_lock = _httpx_client ._shared_http_clients_lock
372+
373+ try :
374+ _httpx_client ._shared_http_clients [(1234 , 1 )] = inherited_client
375+
376+ _httpx_client ._reset_shared_http_clients_after_fork ()
377+
378+ assert _httpx_client ._shared_http_clients == {}
379+ assert _httpx_client ._shared_http_clients_lock is not old_lock
380+ finally :
381+ _httpx_client ._shared_http_clients .clear ()
382+
294383 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."""
384+ """Provider owns http_client injection even if client_args already has one."""
296385 stale_http_client = Mock ()
297386 fresh_http_client = Mock ()
298- http_client_factory = Mock (return_value = fresh_http_client )
387+ http_client_provider = Mock ()
388+ http_client_provider .create_http_client .return_value = fresh_http_client
389+ http_client_provider_factory = Mock (return_value = http_client_provider )
299390 model = OpenAIModel (
300391 model_name = "gpt-4" ,
301392 api_key = "test_key" ,
302393 client_args = {"http_client" : stale_http_client , "timeout" : 30 },
303- http_client_factory = http_client_factory ,
394+ http_client_provider_factory = http_client_provider_factory ,
304395 )
305396
306397 with patch ("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI" ) as mock_async_openai :
@@ -354,15 +445,19 @@ async def test_generate_async_simple_text_response(self):
354445
355446 @pytest .mark .asyncio
356447 async def test_generate_async_validation_failure (self ):
357- """Test generate_async converts validation failures to error responses ."""
448+ """Test generate_async returns an error response on invalid request ."""
358449 model = OpenAIModel (model_name = "gpt-4" , api_key = "test_key" )
359450
451+ # Empty contents
360452 request = LlmRequest (contents = [], config = None , tools_dict = {})
361453
362- responses = [response async for response in model .generate_async (request , stream = False )]
454+ responses = []
455+ async for response in model .generate_async (request , stream = False ):
456+ responses .append (response )
457+
363458 assert len (responses ) == 1
364459 assert responses [0 ].error_code == "API_ERROR"
365- assert "At least one content is required" in ( responses [0 ].error_message or "" )
460+ assert "At least one content is required" in responses [0 ].error_message
366461
367462 @pytest .mark .asyncio
368463 async def test_generate_async_with_config_parameters (self ):
@@ -849,47 +944,3 @@ def test_null_prompt_tokens_details_does_not_crash(self):
849944 }
850945 meta = OpenAIModel ._build_usage_metadata (usage_data )
851946 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