12 known failures — all from Agent 3 new unverified tests (mock setup issues): - 3 dataset_review_routes_extended (DTO field mismatches) - 1 settings_consolidated (dict key access) - 1 llm_analysis_service_coverage (rate_limit mock) - 1 migration_plugin (SessionLocal side_effect exhaustion) - 1 preview (DB query vs dict key) - 5 scheduler (datetime timezone + async mock mismatches) NEW TEST FILES THIS SESSION: - test_batch_insert_coverage.py — 3 tests - test_storage_plugin.py — +3 tests - test_search.py — +2 tests - test_mapper.py — already 100% - test_llm_analysis_migration_v1_to_v2.py — 14 tests - test_llm_async_http.py — +1 test - test_prompt_builder.py — +1 test - test_service_datasource.py — +1 test - test_lang_detect.py — +1 test - test_scheduler.py — +6 tests - test_llm_analysis_service_coverage.py — +15 tests - test_dataset_review_routes_extended.py — +14 tests - test_settings_consolidated.py — +13 tests Modules pushed to 100%: _batch_insert, dictionary_entries, service_datasource, _llm_async_http, prompt_builder, dictionary_crud, _batch_sizer, storage/plugin, mapper.py Session: 7194→7643 tests (+449), 80%→83% raw (+3pp), 93.4%→96.9% real (+3.5pp). Remaining: 12 failures to fix + ~300 statements to reach 98% real coverage.
485 lines
20 KiB
Python
485 lines
20 KiB
Python
# #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
|