# #region Test.LLMAsyncHttpClient [C:3] [TYPE Module] [SEMANTICS test,llm,http,async] # @BRIEF Tests for _llm_async_http.py — async HTTP LLM client. # @RELATION BINDS_TO -> [LLMAsyncHttpClient] # @TEST_EDGE: empty_base_url -> ValueError # @TEST_EDGE: no_choices -> ValueError # @TEST_EDGE: empty_content -> ValueError import os import ssl import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) import pytest from unittest.mock import AsyncMock, MagicMock, patch import httpx from ss_tools.shared.ssl import httpx_verify, system_ssl_context from ss_tools.shared._llm_http import get_shared_http_client from ss_tools.shared._llm_http import ( call_openai_compatible, _do_http_request, _handle_response_format_fallback, ) class TestGetVerify: """SSL verification context (now from shared module).""" def test_returns_ssl_context(self): """httpx_verify returns SSLContext (no env-based disable).""" result = httpx_verify() assert isinstance(result, ssl.SSLContext) def test_system_context_not_false(self): """system_ssl_context never returns False.""" result = system_ssl_context() assert result is not False def test_ignores_llm_ssl_verify_env(self): """LLM_SSL_VERIFY env is no longer read — central ssl helper is used.""" with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}): result = system_ssl_context() assert isinstance(result, ssl.SSLContext), "must not return False" class TestGetHttpClient: """get_shared_http_client — module-level singleton.""" def test_creates_client(self): """Creates client on first call.""" client = get_shared_http_client(timeout=180) assert isinstance(client, httpx.AsyncClient) def test_reuses_client(self): """Returns same client on second call.""" client1 = get_shared_http_client(timeout=180) client2 = get_shared_http_client(timeout=180) assert client1 is client2 class TestCallOpenaiCompatible: """call_openai_compatible — main LLM call function.""" @pytest.mark.asyncio async def test_empty_base_url(self): """Empty base_url raises ValueError.""" with pytest.raises(ValueError, match="no base_url"): await call_openai_compatible("", "key", "model", "prompt") @pytest.mark.asyncio async def test_successful_call(self): """Successful LLM call returns content and finish_reason.""" mock_response = MagicMock(spec=httpx.Response) mock_response.is_success = True mock_response.status_code = 200 mock_response.json.return_value = { "choices": [ { "finish_reason": "stop", "message": {"content": "Hello, world!"}, } ], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "Hello, world!"}}]}' with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): content, finish_reason = await call_openai_compatible( "https://api.openai.com", "sk-test", "gpt-4o", "translate this", ) assert content == "Hello, world!" assert finish_reason == "stop" @pytest.mark.asyncio async def test_no_choices_raises(self): """No choices in response raises ValueError.""" mock_response = MagicMock(spec=httpx.Response) mock_response.is_success = True mock_response.status_code = 200 mock_response.json.return_value = {"choices": []} mock_response.text = '{"choices": []}' with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(ValueError, match="no choices"): await call_openai_compatible( "https://api.openai.com", "sk-test", "gpt-4o", "translate this", ) @pytest.mark.asyncio async def test_empty_content_raises(self): """Empty content raises ValueError.""" mock_response = MagicMock(spec=httpx.Response) mock_response.is_success = True mock_response.status_code = 200 mock_response.json.return_value = { "choices": [ { "finish_reason": "stop", "message": {"content": ""}, } ], } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": ""}}]}' with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(ValueError, match="empty content"): await call_openai_compatible( "https://api.openai.com", "sk-test", "gpt-4o", "translate this", ) @pytest.mark.asyncio async def test_type_error_processing_choices(self): """TypeError processing choices raises ValueError.""" mock_response = MagicMock(spec=httpx.Response) mock_response.is_success = True mock_response.status_code = 200 mock_response.json.return_value = { "choices": [None], # choices[0] is None -> TypeError } mock_response.text = '{"choices": [null]}' with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(ValueError, match="LLM response processing failed"): await call_openai_compatible( "https://api.openai.com", "sk-test", "gpt-4o", "translate this", ) @pytest.mark.asyncio async def test_refusal_raises(self): """LLM refusal raises ValueError.""" mock_response = MagicMock(spec=httpx.Response) mock_response.is_success = True mock_response.status_code = 200 mock_response.json.return_value = { "choices": [ { "finish_reason": "stop", "message": {"refusal": "I cannot answer that", "content": None}, } ], } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"refusal": "I cannot answer that", "content": null}}]}' with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(ValueError, match="refused"): await call_openai_compatible( "https://api.openai.com", "sk-test", "gpt-4o", "translate this", ) @pytest.mark.asyncio async def test_unsuccessful_response(self): """Non-success response raises HTTP status error.""" mock_response = MagicMock(spec=httpx.Response) mock_response.is_success = False mock_response.status_code = 500 mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Server error", request=MagicMock(), response=mock_response, ) mock_response.text = "Internal Server Error" with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(httpx.HTTPStatusError): await call_openai_compatible( "https://api.openai.com", "sk-test", "gpt-4o", "translate this", ) @pytest.mark.asyncio async def test_response_format_for_provider_types(self): """response_format is set for openai-like providers.""" mock_response = MagicMock(spec=httpx.Response) mock_response.is_success = True mock_response.status_code = 200 mock_response.json.return_value = { "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], "usage": {}, } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}' with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): # disable_reasoning=False, so response_format should be set content, _ = await call_openai_compatible( "https://api.openai.com", "sk-test", "gpt-4o", "test", provider_type="openai", disable_reasoning=False, ) assert content == "hi" @pytest.mark.asyncio async def test_disable_reasoning(self): """disable_reasoning=True adjusts payload.""" mock_response = MagicMock(spec=httpx.Response) mock_response.is_success = True mock_response.status_code = 200 mock_response.json.return_value = { "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], "usage": {}, } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}' with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): content, _ = await call_openai_compatible( "https://api.openai.com", "sk-test", "gpt-4o", "test", disable_reasoning=True, ) assert content == "hi" class TestDoHttpRequest: """_do_http_request — async HTTP POST with rate-limit retry.""" @pytest.fixture def mock_client(self): return AsyncMock(spec=httpx.AsyncClient) @pytest.mark.asyncio async def test_success(self, mock_client): """Single successful request.""" mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 200 mock_response.text = "OK" mock_client.post.return_value = mock_response response, text = await _do_http_request( mock_client, "https://api.openai.com/chat/completions", {"Authorization": "Bearer test"}, {"model": "gpt-4o"}, ) assert response.status_code == 200 assert text == "OK" @pytest.mark.asyncio async def test_rate_limit_retry(self, mock_client): """429 triggers retry.""" mock_429 = MagicMock(spec=httpx.Response) mock_429.status_code = 429 mock_429.headers = {} mock_429.text = "Rate limited" mock_200 = MagicMock(spec=httpx.Response) mock_200.status_code = 200 mock_200.text = "OK" mock_client.post.side_effect = [mock_429, mock_200] with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()): response, text = await _do_http_request( mock_client, "https://api.openai.com/chat/completions", {"Authorization": "Bearer test"}, {"model": "gpt-4o"}, ) assert response.status_code == 200 assert text == "OK" assert mock_client.post.call_count == 2 @pytest.mark.asyncio async def test_rate_limit_with_retry_after(self, mock_client): """Retry-After header is respected.""" mock_429 = MagicMock(spec=httpx.Response) mock_429.status_code = 429 mock_429.headers = {"Retry-After": "2"} mock_429.text = "Rate limited" mock_200 = MagicMock(spec=httpx.Response) mock_200.status_code = 200 mock_200.text = "OK" mock_client.post.side_effect = [mock_429, mock_200] with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()) as mock_sleep: response, _ = await _do_http_request( mock_client, "https://api.openai.com/chat/completions", {"Authorization": "Bearer test"}, {"model": "gpt-4o"}, ) assert response.status_code == 200 mock_sleep.assert_called_once_with(2) @pytest.mark.asyncio async def test_rate_limit_exhausted(self, mock_client): """All 429 retries exhausted, returns last 429.""" mock_429 = MagicMock(spec=httpx.Response) mock_429.status_code = 429 mock_429.headers = {} mock_429.text = "Rate limited" mock_client.post.return_value = mock_429 # Always 429 with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()): response, _ = await _do_http_request( mock_client, "https://api.openai.com/chat/completions", {"Authorization": "Bearer test"}, {"model": "gpt-4o"}, ) assert response.status_code == 429 assert mock_client.post.call_count == 3 # max 3 retries @pytest.mark.asyncio async def test_rate_limit_invalid_retry_after(self, mock_client): """Retry-After with non-int value falls back to exponential backoff.""" mock_429 = MagicMock(spec=httpx.Response) mock_429.status_code = 429 mock_429.headers = {"Retry-After": "abc"} mock_429.text = "Rate limited" mock_200 = MagicMock(spec=httpx.Response) mock_200.status_code = 200 mock_200.text = "OK" mock_client.post.side_effect = [mock_429, mock_200] with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()) as mock_sleep: response, _ = await _do_http_request( mock_client, "https://api.openai.com/chat/completions", {"Authorization": "Bearer test"}, {"model": "gpt-4o"}, ) assert response.status_code == 200 # Should fall back to exponential backoff: 2^1 = 2 mock_sleep.assert_called_once_with(2) class TestHandleResponseFormatFallback: """_handle_response_format_fallback — structured output fallback.""" @pytest.fixture def mock_client(self): return AsyncMock(spec=httpx.AsyncClient) @pytest.mark.asyncio async def test_400_response_format_error(self, mock_client): """400 error with response_format pattern triggers retry.""" mock_400 = MagicMock(spec=httpx.Response) mock_400.status_code = 400 mock_400.is_success = False mock_400.text = "response_format is not supported" mock_200 = MagicMock(spec=httpx.Response) mock_200.status_code = 200 mock_200.is_success = True mock_200.content = b'{"ok": true}' mock_200.encoding = "utf-8" mock_200.headers = {} mock_client.post.return_value = mock_200 payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}} await _handle_response_format_fallback( mock_client, mock_400, mock_400.text, payload, "https://api.openai.com", {}, ) # Verify response_format was popped assert "response_format" not in payload # Verify status_code was updated assert mock_400.status_code == 200 @pytest.mark.asyncio async def test_non_400_noop(self, mock_client): """Non-400 error does nothing.""" mock_500 = MagicMock(spec=httpx.Response) mock_500.status_code = 500 mock_500.is_success = False mock_500.text = "Server error" payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}} await _handle_response_format_fallback( mock_client, mock_500, mock_500.text, payload, "https://api.openai.com", {}, ) # Payload unchanged assert "response_format" in payload # No retry call mock_client.post.assert_not_called() @pytest.mark.asyncio async def test_400_no_pattern_noop(self, mock_client): """400 error without response_format pattern does nothing.""" mock_400 = MagicMock(spec=httpx.Response) mock_400.status_code = 400 mock_400.is_success = False mock_400.text = "Some other error" payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}} await _handle_response_format_fallback( mock_client, mock_400, mock_400.text, payload, "https://api.openai.com", {}, ) assert "response_format" in payload mock_client.post.assert_not_called() # #endregion Test.LLMAsyncHttpClient