# #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 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 src.plugins.translate._llm_async_http import ( _get_verify, _get_http_client, call_openai_compatible, _do_http_request, _handle_response_format_fallback, ) class TestGetVerify: """_get_verify — SSL verification context.""" def test_verify_true_default(self): """Default (true) returns SSLContext.""" with patch.dict(os.environ, {"LLM_SSL_VERIFY": "true"}, clear=True): result = _get_verify() assert isinstance(result, bool) is False # It's an SSLContext def test_verify_false(self): """False returns False.""" with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}, clear=True): result = _get_verify() assert result is False def test_verify_off(self): """'off' returns False.""" with patch.dict(os.environ, {"LLM_SSL_VERIFY": "off"}, clear=True): result = _get_verify() assert result is False def test_verify_0(self): """'0' returns False.""" with patch.dict(os.environ, {"LLM_SSL_VERIFY": "0"}, clear=True): result = _get_verify() assert result is False def test_verify_no(self): """'no' returns False.""" with patch.dict(os.environ, {"LLM_SSL_VERIFY": "no"}, clear=True): result = _get_verify() assert result is False class TestGetHttpClient: """_get_http_client — module-level singleton.""" @pytest.mark.asyncio async def test_creates_client(self): """Creates client on first call.""" client = await _get_http_client() assert isinstance(client, httpx.AsyncClient) @pytest.mark.asyncio async def test_reuses_client(self): """Returns same client on second call.""" client1 = await _get_http_client() client2 = await _get_http_client() 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("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("src.plugins.translate._llm_async_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("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("src.plugins.translate._llm_async_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("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("src.plugins.translate._llm_async_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("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("src.plugins.translate._llm_async_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("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("src.plugins.translate._llm_async_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("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("src.plugins.translate._llm_async_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("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("src.plugins.translate._llm_async_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("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): with patch("src.plugins.translate._llm_async_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.mark.asyncio async def test_success(self): """Single successful request.""" mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 200 mock_response.text = "OK" with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: mock_client = AsyncMock() mock_get.return_value = mock_client mock_client.post.return_value = mock_response response, text = await _do_http_request( "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): """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" with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: mock_client = AsyncMock() mock_get.return_value = mock_client mock_client.post.side_effect = [mock_429, mock_200] with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()): response, text = await _do_http_request( "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): """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" with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: mock_client = AsyncMock() mock_get.return_value = mock_client mock_client.post.side_effect = [mock_429, mock_200] with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()) as mock_sleep: response, _ = await _do_http_request( "https://api.openai.com/chat/completions", {"Authorization": "Bearer test"}, {"model": "gpt-4o"}, ) assert response.status_code == 200 # Verify sleep was called with retry-after value mock_sleep.assert_called_once_with(2) @pytest.mark.asyncio async def test_rate_limit_exhausted(self): """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" with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: mock_client = AsyncMock() mock_get.return_value = mock_client mock_client.post.return_value = mock_429 # Always 429 with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()): response, _ = await _do_http_request( "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): """Retry-After with non-int value falls back to exponential backoff (lines 215-216).""" 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" with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: mock_client = AsyncMock() mock_get.return_value = mock_client mock_client.post.side_effect = [mock_429, mock_200] with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()) as mock_sleep: response, _ = await _do_http_request( "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.mark.asyncio async def test_400_response_format_error(self): """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 = {} payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}} with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: mock_client = AsyncMock() mock_get.return_value = mock_client mock_client.post.return_value = mock_200 await _handle_response_format_fallback( 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): """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"}} with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: mock_client = AsyncMock() mock_get.return_value = mock_client await _handle_response_format_fallback( 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): """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"}} with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: mock_client = AsyncMock() mock_get.return_value = mock_client await _handle_response_format_fallback( mock_400, mock_400.text, payload, "https://api.openai.com", {}, ) assert "response_format" in payload mock_client.post.assert_not_called() # #endregion Test.LLMAsyncHttpClient