# #region TestLLMAsyncHttp [C:3] [TYPE Module] [SEMANTICS test, translate, llm, async, httpx, rate-limit] # @BRIEF Tests for async LLM HTTP client — rate-limit retry, asyncio.sleep backoff, structured output fallback. # @RELATION BINDS_TO -> [LLMAsyncHttpClient] # @RELATION BINDS_TO -> [call_openai_compatible] # @TEST_CONTRACT: 429 retry uses asyncio.sleep -> no event loop block # @TEST_EDGE: rate_limit_429_retries -> VERIFIED_BY: test_llm_rate_limit_uses_asyncio_sleep # @TEST_EDGE: empty_choices_raises -> VERIFIED_BY: test_call_openai_compatible_empty_choices # @TEST_EDGE: missing_base_url_raises -> VERIFIED_BY: test_call_openai_compatible_no_base_url import asyncio from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest # #region test_llm_rate_limit_uses_asyncio_sleep [C:2] [TYPE Function] # @BRIEF Verify 429 backoff doesn't block other requests — mock httpx with 2 failures then success. # @TEST_EDGE: rate_limit_429_retries @pytest.mark.asyncio async def test_llm_rate_limit_uses_asyncio_sleep(): """Verify call_openai_compatible retries on 429 using asyncio.sleep, not time.sleep.""" from ss_tools.shared._llm_http import call_openai_compatible call_count = 0 def mock_post_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count <= 2: resp = MagicMock(spec=httpx.Response) resp.status_code = 429 resp.ok = False resp.is_success = False resp.text = "Rate limited" resp.headers = {"Retry-After": "1"} resp.json.return_value = {} return resp resp = MagicMock(spec=httpx.Response) resp.status_code = 200 resp.ok = True resp.is_success = True resp.text = '{"choices":[{"message":{"content":"{\\"translated\\": \\"hola\\"}"},"finish_reason":"stop"}]}' resp.json.return_value = { "choices": [{"message": {"content": '{"translated": "hola"}'}, "finish_reason": "stop"}] } return resp mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=mock_post_side_effect) with patch("ss_tools.shared._llm_http.get_shared_http_client", return_value=mock_client): # Patch asyncio.sleep to verify it's called (not time.sleep) sleep_calls = [] original_sleep = asyncio.sleep async def tracking_sleep(seconds): sleep_calls.append(seconds) await original_sleep(0) # Don't actually wait with patch("ss_tools.shared._llm_http.asyncio.sleep", side_effect=tracking_sleep): content, finish_reason = await call_openai_compatible( base_url="http://fake-llm.local", api_key="test-key", model="gpt-4", prompt="translate: hello", ) assert call_count == 3, f"Expected 3 HTTP calls (2 retries + 1 success), got {call_count}" assert len(sleep_calls) == 2, f"Expected 2 sleep calls for 429 retry, got {len(sleep_calls)}" assert finish_reason == "stop" # #endregion test_llm_rate_limit_uses_asyncio_sleep # #region test_call_openai_compatible_no_base_url [C:2] [TYPE Function] # @BRIEF Verify call_openai_compatible raises ValueError when base_url is empty. # @TEST_EDGE: missing_base_url_raises @pytest.mark.asyncio async def test_call_openai_compatible_no_base_url(): """Verify empty base_url raises ValueError immediately.""" from ss_tools.shared._llm_http import call_openai_compatible with pytest.raises(ValueError, match="no base_url"): await call_openai_compatible( base_url="", api_key="test-key", model="gpt-4", prompt="hello", ) # #endregion test_call_openai_compatible_no_base_url # #region test_call_openai_compatible_empty_choices [C:2] [TYPE Function] # @BRIEF Verify call_openai_compatible raises ValueError when LLM returns empty choices. # @TEST_EDGE: empty_choices_raises @pytest.mark.asyncio async def test_call_openai_compatible_empty_choices(): """Verify LLM response with empty choices raises ValueError.""" from ss_tools.shared._llm_http import call_openai_compatible mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 200 mock_response.is_success = True mock_response.json.return_value = {"choices": []} mock_response.text = '{"choices": []}' mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) with patch("ss_tools.shared._llm_http.get_shared_http_client", return_value=mock_client): 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( base_url="http://fake-llm.local", api_key="test-key", model="gpt-4", prompt="hello", ) # #endregion test_call_openai_compatible_empty_choices # #region test_call_openai_compatible_refusal [C:2] [TYPE Function] # @BRIEF Verify call_openai_compatible raises ValueError when LLM returns a refusal. # @TEST_EDGE: llm_refusal_raises @pytest.mark.asyncio async def test_call_openai_compatible_refusal(): """Verify LLM refusal response raises ValueError.""" from ss_tools.shared._llm_http import call_openai_compatible mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 200 mock_response.is_success = True mock_response.json.return_value = { "choices": [{"message": {"content": "", "refusal": "I cannot translate this"}, "finish_reason": "stop"}] } mock_response.text = '{"choices": [{"message": {"content": "", "refusal": "I cannot translate this"}, "finish_reason": "stop"}]}' mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) with patch("ss_tools.shared._llm_http.get_shared_http_client", return_value=mock_client): 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( base_url="http://fake-llm.local", api_key="test-key", model="gpt-4", prompt="hello", ) # #endregion test_call_openai_compatible_refusal # #region test_retry_does_not_block_event_loop [C:2] [TYPE Function] # @BRIEF Verify 429 retry sleep doesn't block concurrent tasks on the same event loop. # @TEST_INVARIANT: retry_backoff_non_blocking -> VERIFIED_BY: test_retry_does_not_block_event_loop @pytest.mark.asyncio async def test_retry_does_not_block_event_loop(): """Verify that 429 retry backoff allows other coroutines to run.""" from ss_tools.shared._llm_http import _do_http_request concurrent_ran = False async def concurrent_task(): nonlocal concurrent_ran await asyncio.sleep(0.01) concurrent_ran = True call_count = 0 def mock_post(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: resp = MagicMock(spec=httpx.Response) resp.status_code = 429 resp.is_success = False resp.text = "Rate limited" resp.headers = {"Retry-After": "1"} return resp resp = MagicMock(spec=httpx.Response) resp.status_code = 200 resp.is_success = True resp.text = "ok" return resp mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=mock_post) with patch("ss_tools.shared._llm_http.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: # Run the HTTP request alongside a concurrent task http_task = asyncio.create_task(_do_http_request(mock_client, "http://fake", {}, {})) conc_task = asyncio.create_task(concurrent_task()) await asyncio.gather(http_task, conc_task) assert concurrent_ran, "Concurrent task was blocked by 429 retry" assert mock_sleep.called, "asyncio.sleep was not called during 429 retry" # #endregion test_retry_does_not_block_event_loop # #endregion TestLLMAsyncHttp