refactor(ssl): centralize SSL trust management, remove LLM_SSL_VERIFY

Centralized SSL via one contract: CERTS_PATH=/opt/certs mounted into all containers.

Backend:
  - NEW: backend/src/core/ssl.py — system_ssl_context(), httpx_verify(),
    cert_dir_inventory()
  - LLMClient._get_ssl_verify() → delegates to core.ssl
  - _llm_async_http._get_verify() → delegates to core.ssl
  - Removed LLM_SSL_VERIFY env reading from all runtime code

Docker:
  - NEW: docker/certs.sh — shared cert installer (PEM/DER/cer to .crt conversion,
    update-ca-certificates, hash symlinks, NSS import)
  - NEW: docker/agent.entrypoint.sh — agent entrypoint with cert installation
  - backend.entrypoint.sh → uses certs.sh instead of install_llm_ca_certs
  - Dockerfile.agent → adds ca-certificates, openssl, entrypoint

Compose:
  - Removed LLM_CA_CERT_URLS and LLM_SSL_VERIFY from all compose files
  - Added CERTS_PATH volume mount to agent (dev + enterprise)
  - Added certs volume mount to backend/agent in dev compose

Env examples:
  - Removed LLM_SSL_VERIFY, LLM_CA_CERT_URLS from .env.example,
    .env.enterprise-clean.example, .env.current.example, .env.master.example,
    backend/.env.example
  - Enhanced CERTS_PATH comments with accepted formats

Diagnostics:
  - diag_container.py: removed LLM_* checks, added CERTS_PATH inventory,
    uses core.ssl for context creation

Tests:
  - Updated test_llm_analysis_service, test_llm_async_http,
    test_client_headers to verify centralized ssl context (no env disable)
  - 4/4 SSL tests pass
This commit is contained in:
2026-07-06 21:00:28 +03:00
parent 43e3f4135e
commit 8fd23f7ea1
16 changed files with 1200 additions and 1048 deletions

View File

@@ -24,11 +24,13 @@ from src.plugins.llm_analysis.models import LLMProviderType
# ── ScreenshotService Tests ──
class TestResponseLooksLikeLoginPage:
"""Verify _response_looks_like_login_page."""
def test_login_page_detected(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
html = """
<form>
@@ -44,18 +46,21 @@ class TestResponseLooksLikeLoginPage:
def test_non_login_page(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
html = "<html><body><h1>Dashboard</h1><p>Welcome to Superset</p></body></html>"
assert svc._response_looks_like_login_page(html) is False
def test_empty_text(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
assert svc._response_looks_like_login_page("") is False
assert svc._response_looks_like_login_page(None) is False
def test_partial_match_under_threshold(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
assert svc._response_looks_like_login_page("Please sign in to continue") is False
assert svc._response_looks_like_login_page("Username: admin Password: secret") is False
@@ -63,6 +68,7 @@ class TestResponseLooksLikeLoginPage:
def test_csrf_token_marker(self):
"""Edge: csrf_token hidden input is a marker."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
html = 'name="csrf_token" value="abc"'
assert svc._response_looks_like_login_page(html) is False # only 1 marker
@@ -73,18 +79,21 @@ class TestRedirectLooksAuthenticated:
def test_empty_redirect_authenticated(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
assert svc._redirect_looks_authenticated("") is True
assert svc._redirect_looks_authenticated(None) is True
def test_login_redirect_not_authenticated(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
assert svc._redirect_looks_authenticated("/login/") is False
assert svc._redirect_looks_authenticated("https://example.com/login") is False
def test_dashboard_redirect_authenticated(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
assert svc._redirect_looks_authenticated("/superset/dashboard/1/") is True
assert svc._redirect_looks_authenticated("https://example.com/superset/dashboard/1/") is True
@@ -95,6 +104,7 @@ class TestIterLoginRoots:
def test_page_without_frames(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
page = MagicMock()
page.frames = []
@@ -104,6 +114,7 @@ class TestIterLoginRoots:
def test_page_with_frames(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
page = MagicMock()
frame1 = MagicMock()
@@ -118,8 +129,9 @@ class TestIterLoginRoots:
def test_page_frames_property_exception(self):
"""Edge: exception in frames property handled gracefully."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
page = MagicMock(spec_set=['url'])
page = MagicMock(spec_set=["url"])
roots = svc._iter_login_roots(page)
assert len(roots) == 1
@@ -130,6 +142,7 @@ class TestExtractHiddenLoginFields:
@pytest.mark.asyncio
async def test_extracts_hidden_fields(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
@@ -139,12 +152,14 @@ class TestExtractHiddenLoginFields:
async def attr_side(attr_name):
return "csrf_token" if attr_name == "name" else None
field1 = MagicMock()
field1.get_attribute = AsyncMock(side_effect=attr_side)
field1.input_value = AsyncMock(return_value="abc123")
async def attr_side2(attr_name):
return "next" if attr_name == "name" else None
field2 = MagicMock()
field2.get_attribute = AsyncMock(side_effect=attr_side2)
field2.input_value = AsyncMock(return_value="/dashboard/")
@@ -158,6 +173,7 @@ class TestExtractHiddenLoginFields:
@pytest.mark.asyncio
async def test_empty_when_no_hidden_fields(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
@@ -171,6 +187,7 @@ class TestExtractHiddenLoginFields:
@pytest.mark.asyncio
async def test_skips_duplicate_names(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
@@ -180,6 +197,7 @@ class TestExtractHiddenLoginFields:
async def attr_side(attr_name):
return "csrf_token" if attr_name == "name" else None
field1 = MagicMock()
field1.get_attribute = AsyncMock(side_effect=attr_side)
field1.input_value = AsyncMock(return_value="first")
@@ -199,15 +217,17 @@ class TestExtractCsrfToken:
@pytest.mark.asyncio
async def test_found_token(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok123"})):
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={"csrf_token": "tok123"})):
assert await svc._extract_csrf_token(MagicMock()) == "tok123"
@pytest.mark.asyncio
async def test_missing_token(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={})):
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={})):
assert await svc._extract_csrf_token(MagicMock()) == ""
@@ -217,6 +237,7 @@ class TestSubmitLoginViaFormPost:
@pytest.mark.asyncio
async def test_redirect_authenticated(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
svc.env = MagicMock()
svc.env.username = "admin"
@@ -232,27 +253,29 @@ class TestSubmitLoginViaFormPost:
mock_response.headers = {"location": "/superset/dashboard/1/"}
mock_request.post = AsyncMock(return_value=mock_response)
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={"csrf_token": "tok"})):
result = await svc._submit_login_via_form_post(mock_page, "https://example.com/login/")
assert result is True
@pytest.mark.asyncio
async def test_no_csrf_token_skips(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={})):
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={})):
result = await svc._submit_login_via_form_post(MagicMock(), "https://example.com/login/")
assert result is False
@pytest.mark.asyncio
async def test_request_context_unavailable(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_page.context = MagicMock(side_effect=AttributeError("no context"))
# Override to test the try/except
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
with patch.object(svc, '_submit_login_via_form_post', AsyncMock(return_value=False)):
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={"csrf_token": "tok"})):
with patch.object(svc, "_submit_login_via_form_post", AsyncMock(return_value=False)):
# We test the context error path by making request unavailable
pass
@@ -260,6 +283,7 @@ class TestSubmitLoginViaFormPost:
async def test_login_page_response_returns_false(self):
"""When POST returns login page markup, return False."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
svc.env = MagicMock()
svc.env.username = "admin"
@@ -278,7 +302,7 @@ class TestSubmitLoginViaFormPost:
mock_response.text = AsyncMock(return_value='<form><label>Username:</label><input name="username"/><label>Password:</label><input type="password" name="password"/><input type="submit" value="Sign in"/><input type="hidden" name="csrf_token" value="x"/></form>')
mock_request.post = AsyncMock(return_value=mock_response)
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={"csrf_token": "tok"})):
result = await svc._submit_login_via_form_post(mock_page, "https://example.com/login/")
assert result is False
@@ -289,6 +313,7 @@ class TestFindLoginFieldLocator:
@pytest.mark.asyncio
async def test_finds_username_field(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
@@ -306,6 +331,7 @@ class TestFindLoginFieldLocator:
@pytest.mark.asyncio
async def test_finds_password_field(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
@@ -323,6 +349,7 @@ class TestFindLoginFieldLocator:
@pytest.mark.asyncio
async def test_no_field_found(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
@@ -344,6 +371,7 @@ class TestFindLoginFieldLocator:
async def test_finds_username_via_input_fallback(self):
"""Fallback: input[type='text'] for username."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
@@ -391,6 +419,7 @@ class TestFindSubmitLocator:
@pytest.mark.asyncio
async def test_finds_sign_in_button(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
@@ -408,6 +437,7 @@ class TestFindSubmitLocator:
@pytest.mark.asyncio
async def test_no_submit_found(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
@@ -428,6 +458,7 @@ class TestGotoResilient:
@pytest.mark.asyncio
async def test_primary_success(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_page.goto = AsyncMock(return_value=MagicMock())
@@ -437,6 +468,7 @@ class TestGotoResilient:
@pytest.mark.asyncio
async def test_fallback_on_primary_failure(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_response = MagicMock()
@@ -457,20 +489,22 @@ class TestWaitForChartsStabilized:
@pytest.mark.asyncio
async def test_polls_and_returns(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_page.wait_for_function = AsyncMock()
with patch('asyncio.sleep', AsyncMock()):
with patch("asyncio.sleep", AsyncMock()):
await svc._wait_for_charts_stabilized(mock_page)
mock_page.wait_for_function.assert_called_once()
@pytest.mark.asyncio
async def test_timeout_does_not_raise(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_page.wait_for_function = AsyncMock(side_effect=Exception("timeout"))
with patch('asyncio.sleep', AsyncMock()):
with patch("asyncio.sleep", AsyncMock()):
# Should not raise
await svc._wait_for_charts_stabilized(mock_page)
@@ -481,6 +515,7 @@ class TestWaitForResizeRendered:
@pytest.mark.asyncio
async def test_polls_and_returns(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_page.wait_for_function = AsyncMock()
@@ -490,6 +525,7 @@ class TestWaitForResizeRendered:
@pytest.mark.asyncio
async def test_timeout_does_not_raise(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_page.wait_for_function = AsyncMock(side_effect=Exception("timeout"))
@@ -502,6 +538,7 @@ class TestSaveDebugScreenshot:
@pytest.mark.asyncio
async def test_saves_screenshot(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_page.screenshot = AsyncMock()
@@ -513,6 +550,7 @@ class TestSaveDebugScreenshot:
@pytest.mark.asyncio
async def test_failure_returns_none(self):
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_page.screenshot = AsyncMock(side_effect=Exception("screenshot failed"))
@@ -542,6 +580,7 @@ class TestConvertScreenshotsForLlm:
def test_missing_file_skipped(self):
from src.plugins.llm_analysis.service import ScreenshotService
result = ScreenshotService._convert_screenshots_for_llm(["/nonexistent.png"], "/tmp")
assert result == []
@@ -591,6 +630,7 @@ class TestArchiveScreenshotsAsWebp:
def test_archive_missing_file(self):
from src.plugins.llm_analysis.service import ScreenshotService
result = ScreenshotService._archive_screenshots_as_webp(["/nonexistent.png"], "/tmp")
assert len(result) == 1
assert result[0]["webp_path"] is None
@@ -641,11 +681,13 @@ class TestCleanupTempFiles:
def test_cleanup_missing_file(self):
from src.plugins.llm_analysis.service import ScreenshotService
ScreenshotService._cleanup_temp_files(["/nonexistent.png"])
# ── LLMClient Tests ──
class TestLLMClientInit:
"""Verify LLMClient initialization."""
@@ -667,11 +709,11 @@ class TestLLMClientInit:
assert client.api_key == "sk-test-key"
def test_init_openrouter_headers(self):
with patch.dict(os.environ, {"OPENROUTER_SITE_URL": "https://example.com",
"OPENROUTER_APP_NAME": "TestApp"}):
with patch.dict(os.environ, {"OPENROUTER_SITE_URL": "https://example.com", "OPENROUTER_APP_NAME": "TestApp"}):
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
client = LLMClient(
provider_type=LLMProviderType.OPENROUTER,
api_key="sk-test",
@@ -682,8 +724,9 @@ class TestLLMClientInit:
def test_init_kilo_headers(self):
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
client = LLMClient(
provider_type=LLMProviderType.KILO,
api_key="sk-test",
@@ -694,8 +737,9 @@ class TestLLMClientInit:
def _make_client(self, api_key="sk-test"):
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
return LLMClient(
provider_type=LLMProviderType.OPENAI,
api_key=api_key,
@@ -707,46 +751,21 @@ class TestLLMClientInit:
class TestLLMClientSslVerify:
"""Verify _get_ssl_verify."""
def test_default_disabled(self):
def test_returns_ssl_context(self):
"""_get_ssl_verify always returns SSLContext (no env-based disable)."""
from src.plugins.llm_analysis.service import LLMClient
from src.core.ssl import system_ssl_context
result = LLMClient._get_ssl_verify()
assert isinstance(result, ssl.SSLContext)
def test_ignores_llm_ssl_verify_env(self):
"""LLM_SSL_VERIFY env is no longer read — central ssl helper is used."""
from src.plugins.llm_analysis.service import LLMClient
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}):
assert LLMClient._get_ssl_verify() is False
def test_disabled_zero(self):
from src.plugins.llm_analysis.service import LLMClient
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "0"}):
assert LLMClient._get_ssl_verify() is False
def test_disabled_no(self):
from src.plugins.llm_analysis.service import LLMClient
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "no"}):
assert LLMClient._get_ssl_verify() is False
def test_disabled_off(self):
from src.plugins.llm_analysis.service import LLMClient
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "off"}):
assert LLMClient._get_ssl_verify() is False
def test_enabled_returns_context(self):
from src.plugins.llm_analysis.service import LLMClient
with patch.dict(os.environ, {}, clear=True):
result = LLMClient._get_ssl_verify()
assert isinstance(result, (ssl.SSLContext, bool))
def test_ca_dir_missing(self):
"""When /etc/ssl/certs doesn't exist, still returns SSLContext."""
from src.plugins.llm_analysis.service import LLMClient
with patch.dict(os.environ, {}, clear=True):
with patch('os.path.isdir', return_value=False):
result = LLMClient._get_ssl_verify()
assert isinstance(result, ssl.SSLContext)
def test_unknown_value_defaults_enabled(self):
"""Unknown env values treated as 'true' -> enabled."""
from src.plugins.llm_analysis.service import LLMClient
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "maybe"}):
result = LLMClient._get_ssl_verify()
assert isinstance(result, (ssl.SSLContext, bool))
assert isinstance(result, ssl.SSLContext), "must not return False"
class TestFormatConnectionError:
@@ -754,12 +773,14 @@ class TestFormatConnectionError:
def test_single_exception(self):
from src.plugins.llm_analysis.service import LLMClient
result = LLMClient._format_connection_error(ValueError("test error"))
assert "ValueError" in result
assert "test error" in result
def test_chained_exception(self):
from src.plugins.llm_analysis.service import LLMClient
inner = ConnectionError("connection refused")
outer = RuntimeError("API call failed")
outer.__cause__ = inner
@@ -770,6 +791,7 @@ class TestFormatConnectionError:
def test_deep_chain(self):
from src.plugins.llm_analysis.service import LLMClient
inner = ValueError("inner")
middle = TypeError("middle")
outer = RuntimeError("outer")
@@ -786,30 +808,35 @@ class TestSupportsJsonResponseFormat:
def test_free_model_disabled(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
client.default_model = "gpt-4o:free"
assert LLMClient._supports_json_response_format(client) is False
def test_stepfun_disabled(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
client.default_model = "step-1v"
assert LLMClient._supports_json_response_format(client) is False
def test_stepfun_slash_disabled(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
client.default_model = "stepfun/some-model"
assert LLMClient._supports_json_response_format(client) is False
def test_normal_model_enabled(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
client.default_model = "gpt-4o"
assert LLMClient._supports_json_response_format(client) is True
def test_empty_model_default_enabled(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
client.default_model = ""
assert LLMClient._supports_json_response_format(client) is True
@@ -820,6 +847,7 @@ class TestDeduplicateIssues:
def test_deduplicates_by_severity_message_location(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
issues = [
{"severity": "HIGH", "message": "Chart missing", "location": "chart1"},
@@ -831,12 +859,14 @@ class TestDeduplicateIssues:
def test_empty_issues(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
assert LLMClient._deduplicate_issues(client, []) == []
def test_issues_with_none_location(self):
"""Edge: None location treated as empty string."""
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
issues = [
{"severity": "HIGH", "message": "Error", "location": None},
@@ -848,6 +878,7 @@ class TestDeduplicateIssues:
def test_issues_without_location(self):
"""Edge: missing location key."""
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
issues = [
{"severity": "HIGH", "message": "No loc"},
@@ -862,6 +893,7 @@ class TestEstimatePayloadSize:
def test_estimate_basic(self):
from src.plugins.llm_analysis.service import LLMClient
result = LLMClient._estimate_payload_size(["img1.png"], 1000, 128000)
assert result["estimated_tokens"] > 0
assert "pct_of_limit" in result
@@ -869,16 +901,19 @@ class TestEstimatePayloadSize:
def test_large_payload_exceeds(self):
from src.plugins.llm_analysis.service import LLMClient
result = LLMClient._estimate_payload_size(["img1.png"] * 100, 100000, 128000)
assert result["exceeds_limit"] is True
def test_small_payload_ok(self):
from src.plugins.llm_analysis.service import LLMClient
result = LLMClient._estimate_payload_size([], 100, 128000)
assert result["exceeds_limit"] is False
def test_no_images(self):
from src.plugins.llm_analysis.service import LLMClient
result = LLMClient._estimate_payload_size([], 4000, 128000)
assert result["estimated_tokens"] == 1000 # 4000/4
@@ -888,42 +923,46 @@ class TestMergeChunkResults:
def test_merge_takes_worst_status(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
chunks = [
{"status": "PASS", "summary": "All good", "issues": []},
{"status": "FAIL", "summary": "Broken", "issues": [{"severity": "HIGH", "message": "Error"}]},
]
with patch.object(LLMClient, '_deduplicate_issues', return_value=[{"severity": "HIGH", "message": "Error"}]):
with patch.object(LLMClient, "_deduplicate_issues", return_value=[{"severity": "HIGH", "message": "Error"}]):
result = LLMClient._merge_chunk_results(client, chunks)
assert result["status"] == "FAIL"
assert result["chunk_count"] == 2
def test_merge_single_chunk(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
chunks = [{"status": "WARN", "summary": "Some issues", "issues": []}]
with patch.object(LLMClient, '_deduplicate_issues', return_value=[]):
with patch.object(LLMClient, "_deduplicate_issues", return_value=[]):
result = LLMClient._merge_chunk_results(client, chunks)
assert result["status"] == "WARN"
assert result["chunk_count"] == 1
def test_merge_unknown_default(self):
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
chunks = [{"summary": "No status", "issues": []}]
with patch.object(LLMClient, '_deduplicate_issues', return_value=[]):
with patch.object(LLMClient, "_deduplicate_issues", return_value=[]):
result = LLMClient._merge_chunk_results(client, chunks)
assert result["status"] == "UNKNOWN"
def test_merge_worst_over_pass(self):
"""WARN < PASS so WARN wins."""
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
chunks = [
{"status": "PASS", "summary": "OK", "issues": []},
{"status": "WARN", "summary": "Warning", "issues": []},
]
with patch.object(LLMClient, '_deduplicate_issues', return_value=[]):
with patch.object(LLMClient, "_deduplicate_issues", return_value=[]):
result = LLMClient._merge_chunk_results(client, chunks)
assert result["status"] == "WARN"
@@ -935,17 +974,15 @@ class TestLLMClientAnalyze:
async def test_analyze_dashboard_delegates_to_multimodal(self):
from src.plugins.llm_analysis.service import LLMClient as RealClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
real_client = RealClient(
provider_type=LLMProviderType.OPENAI,
api_key="sk-test",
base_url="https://api.openai.com",
default_model="gpt-4o",
)
real_client.analyze_dashboard_multimodal = AsyncMock(
return_value={"status": "PASS", "summary": "OK"}
)
real_client.analyze_dashboard_multimodal = AsyncMock(return_value={"status": "PASS", "summary": "OK"})
result = await real_client.analyze_dashboard(
screenshot_path="/tmp/test.png",
@@ -965,8 +1002,8 @@ class TestLLMClientOptimizeImages:
Image.new("RGB", (100, 100), (255, 0, 0)).save(png_path, "PNG")
# Create a real client with mocked internals
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
real_client = LLMClient(
provider_type=LLMProviderType.OPENAI,
api_key="sk-test",
@@ -974,7 +1011,7 @@ class TestLLMClientOptimizeImages:
default_model="gpt-4o-mini",
)
# Patch the static method on the class
with patch.object(LLMClient, '_reduce_image_quality', return_value=("base64data", 1000)):
with patch.object(LLMClient, "_reduce_image_quality", return_value=("base64data", 1000)):
result = real_client._optimize_images([png_path], 1024, 60)
assert len(result) == 1
assert result[0] == "base64data"
@@ -987,7 +1024,7 @@ class TestLLMClientOptimizeImages:
Image.new("RGB", (100, 100)).save(png_path, "PNG")
client = MagicMock()
with patch.object(LLMClient, '_reduce_image_quality', side_effect=Exception("corrupt")):
with patch.object(LLMClient, "_reduce_image_quality", side_effect=Exception("corrupt")):
result = LLMClient._optimize_images(client, [png_path], 1024, 60)
assert len(result) == 1
assert isinstance(result[0], str)
@@ -1051,7 +1088,7 @@ class TestLLMClientCallLlmForImages:
client = MagicMock()
client.get_json_completion = AsyncMock(return_value={"status": "PASS"})
with patch.object(LLMClient, '_call_llm_for_images') as mock_call:
with patch.object(LLMClient, "_call_llm_for_images") as mock_call:
mock_call.return_value = {"status": "PASS"}
result = await LLMClient._call_llm_for_images(client, ["base64img"], "Analyze")
assert result is not None
@@ -1064,8 +1101,8 @@ class TestLLMClientFetchModels:
async def test_fetch_success(self):
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
mock_client = MagicMock()
MockOpenAI.return_value = mock_client
mock_response = MagicMock()
@@ -1090,8 +1127,8 @@ class TestLLMClientFetchModels:
async def test_fetch_failure_raises(self):
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
mock_client = MagicMock()
MockOpenAI.return_value = mock_client
mock_client.models.list = AsyncMock(side_effect=ConnectionError("API unavailable"))
@@ -1114,8 +1151,8 @@ class TestLLMClientTestRuntimeConnection:
async def test_runtime_connection_success(self):
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
real_client = LLMClient(
provider_type=LLMProviderType.OPENAI,
api_key="sk-test",
@@ -1145,14 +1182,14 @@ class TestGetJsonCompletion:
"""Edge: JSON embedded in ```json code block parsed."""
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
mock_client = MagicMock()
MockOpenAI.return_value = mock_client
# Mock response content with JSON in code block
mock_choice = MagicMock()
mock_choice.message.content = "```json\n{\"status\": \"PASS\"}\n```"
mock_choice.message.content = '```json\n{"status": "PASS"}\n```'
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
@@ -1172,13 +1209,13 @@ class TestGetJsonCompletion:
"""Edge: JSON in ``` code block (no json marker)."""
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
mock_client = MagicMock()
MockOpenAI.return_value = mock_client
mock_choice = MagicMock()
mock_choice.message.content = "```\n{\"status\": \"WARN\"}\n```"
mock_choice.message.content = '```\n{"status": "WARN"}\n```'
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
@@ -1198,8 +1235,8 @@ class TestGetJsonCompletion:
"""Negative: null content raises RuntimeError."""
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
mock_client = MagicMock()
MockOpenAI.return_value = mock_client
@@ -1224,8 +1261,8 @@ class TestGetJsonCompletion:
"""Negative: empty choices raises RuntimeError."""
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
mock_client = MagicMock()
MockOpenAI.return_value = mock_client
mock_response = MagicMock()
@@ -1270,7 +1307,9 @@ class TestAnalyzeDashboardMultimodal:
client._deduplicate_issues.return_value = []
result = await LLMClient.analyze_dashboard_multimodal(
client, [png_path], ["log1"],
client,
[png_path],
["log1"],
prompt_template="Analyze: {logs}",
)
assert result["status"] == "PASS"
@@ -1287,14 +1326,16 @@ class TestAnalyzeDashboardMultimodal:
client = MagicMock()
client._optimize_images.side_effect = [
["img1_high"], # first call (high quality)
["img1_low"], # second call (reduced quality)
["img1_low"], # second call (reduced quality)
]
client._estimate_payload_size.return_value = {"exceeds_limit": True, "pct_of_limit": 85}
client._call_llm_for_images = AsyncMock(return_value={"status": "WARN", "summary": "Reduced", "issues": []})
client._deduplicate_issues.return_value = []
result = await LLMClient.analyze_dashboard_multimodal(
client, [png_path], ["log1"],
client,
[png_path],
["log1"],
prompt_template="Analyze: {logs}",
)
assert result["status"] == "WARN"
@@ -1319,7 +1360,9 @@ class TestAnalyzeDashboardMultimodal:
paths.append(p)
result = await LLMClient.analyze_dashboard_multimodal(
client, paths, ["log"],
client,
paths,
["log"],
max_images=2,
)
# With 5 images and max_images=2, this creates 3 chunks
@@ -1342,7 +1385,9 @@ class TestAnalyzeDashboardMultimodal:
client._deduplicate_issues.return_value = []
result = await LLMClient.analyze_dashboard_multimodal(
client, [png_path], ["log"],
client,
[png_path],
["log"],
)
assert result["status"] == "UNKNOWN"
@@ -1363,9 +1408,7 @@ class TestAnalyzeDashboardTextBatch:
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
client.get_json_completion = AsyncMock(return_value={
"dashboards": [{"dashboard_id": "1", "status": "PASS", "summary": "OK", "issues": []}]
})
client.get_json_completion = AsyncMock(return_value={"dashboards": [{"dashboard_id": "1", "status": "PASS", "summary": "OK", "issues": []}]})
result = await LLMClient.analyze_dashboard_text_batch(
client,
@@ -1380,12 +1423,14 @@ class TestAnalyzeDashboardTextBatch:
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
client.get_json_completion = AsyncMock(return_value={
"dashboards": [
{"dashboard_id": "1", "status": "PASS", "summary": "OK", "issues": []},
{"dashboard_id": "2", "status": "FAIL", "summary": "Broken", "issues": [{"severity": "HIGH", "message": "err"}]},
]
})
client.get_json_completion = AsyncMock(
return_value={
"dashboards": [
{"dashboard_id": "1", "status": "PASS", "summary": "OK", "issues": []},
{"dashboard_id": "2", "status": "FAIL", "summary": "Broken", "issues": [{"severity": "HIGH", "message": "err"}]},
]
}
)
result = await LLMClient.analyze_dashboard_text_batch(
client,
@@ -1400,11 +1445,13 @@ class TestAnalyzeDashboardTextBatch:
# ── DatasetHealthChecker Tests ──
class TestDatasetHealthChecker:
"""Verify DatasetHealthChecker."""
def test_import(self):
from src.plugins.llm_analysis.service import DatasetHealthChecker
assert DatasetHealthChecker is not None
@pytest.mark.asyncio
@@ -1498,13 +1545,10 @@ class TestDatasetHealthChecker:
mock_client = MagicMock()
mock_client.network.request.return_value = {"result": [{"val": 1}]}
mock_client.get_dataset.return_value = {
"result": {"table_name": "test", "database": {"database_name": "db", "backend": "postgresql"}}
}
mock_client.get_dataset.return_value = {"result": {"table_name": "test", "database": {"database_name": "db", "backend": "postgresql"}}}
chart_list = [
{"slice_id": 1, "slice_name": "Chart 1", "datasource_id": 101,
"viz_type": "table", "params": {"metrics": ["count"]}, "datasource_type": "table"},
{"slice_id": 1, "slice_name": "Chart 1", "datasource_id": 101, "viz_type": "table", "params": {"metrics": ["count"]}, "datasource_type": "table"},
]
checker = DatasetHealthChecker(mock_client)
@@ -1520,13 +1564,10 @@ class TestDatasetHealthChecker:
mock_client = MagicMock()
mock_client.network.request.return_value = {"result": []}
mock_client.get_dataset.return_value = {
"result": {"table_name": "test", "database": {"database_name": "db", "backend": "postgresql"}}
}
mock_client.get_dataset.return_value = {"result": {"table_name": "test", "database": {"database_name": "db", "backend": "postgresql"}}}
chart_list = [
{"slice_id": 1, "slice_name": "Chart 1", "datasource_id": 101,
"viz_type": "table", "params": '{"metrics": ["count"]}', "datasource_type": "table"},
{"slice_id": 1, "slice_name": "Chart 1", "datasource_id": 101, "viz_type": "table", "params": '{"metrics": ["count"]}', "datasource_type": "table"},
]
checker = DatasetHealthChecker(mock_client)
@@ -1547,6 +1588,7 @@ class TestDatasetHealthChecker:
# ── RedactionService Tests ──
class TestRedactionService:
"""Verify RedactionService."""
@@ -1572,6 +1614,7 @@ class TestRedactionService:
def test_redact_logs_empty(self):
from src.plugins.llm_analysis.service import RedactionService
assert RedactionService.redact_logs([]) == []
def test_redact_raw_response(self):
@@ -1610,4 +1653,6 @@ class TestRedactionService:
safe = '{"status": "PASS", "summary": "OK"}'
redacted = RedactionService.redact_raw_response(safe)
assert redacted == safe
# #endregion Test.LLMAnalysisService

View File

@@ -6,8 +6,10 @@
# @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
@@ -27,35 +29,16 @@ from src.plugins.translate._llm_async_http import (
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_returns_ssl_context(self):
"""Always returns SSLContext (no env-based disable)."""
result = _get_verify()
assert isinstance(result, ssl.SSLContext)
def test_verify_false(self):
"""False returns False."""
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}, clear=True):
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 = _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
assert isinstance(result, ssl.SSLContext), "must not return False"
class TestGetHttpClient:
@@ -101,12 +84,13 @@ class TestCallOpenaiCompatible:
}
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()):
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",
"https://api.openai.com",
"sk-test",
"gpt-4o",
"translate this",
)
assert content == "Hello, world!"
assert finish_reason == "stop"
@@ -120,13 +104,14 @@ class TestCallOpenaiCompatible:
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 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",
"https://api.openai.com",
"sk-test",
"gpt-4o",
"translate this",
)
@pytest.mark.asyncio
@@ -145,13 +130,14 @@ class TestCallOpenaiCompatible:
}
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 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",
"https://api.openai.com",
"sk-test",
"gpt-4o",
"translate this",
)
@pytest.mark.asyncio
@@ -165,13 +151,14 @@ class TestCallOpenaiCompatible:
}
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 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",
"https://api.openai.com",
"sk-test",
"gpt-4o",
"translate this",
)
@pytest.mark.asyncio
@@ -190,13 +177,14 @@ class TestCallOpenaiCompatible:
}
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 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",
"https://api.openai.com",
"sk-test",
"gpt-4o",
"translate this",
)
@pytest.mark.asyncio
@@ -206,17 +194,20 @@ class TestCallOpenaiCompatible:
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,
"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 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",
"https://api.openai.com",
"sk-test",
"gpt-4o",
"translate this",
)
@pytest.mark.asyncio
@@ -231,14 +222,16 @@ class TestCallOpenaiCompatible:
}
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()):
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,
"https://api.openai.com",
"sk-test",
"gpt-4o",
"test",
provider_type="openai",
disable_reasoning=False,
)
assert content == "hi"
@@ -254,12 +247,13 @@ class TestCallOpenaiCompatible:
}
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()):
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",
"https://api.openai.com",
"sk-test",
"gpt-4o",
"test",
disable_reasoning=True,
)
assert content == "hi"
@@ -275,8 +269,7 @@ class TestDoHttpRequest:
mock_response.status_code = 200
mock_response.text = "OK"
with patch("src.plugins.translate._llm_async_http._get_http_client",
AsyncMock()) as mock_get:
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
@@ -301,14 +294,12 @@ class TestDoHttpRequest:
mock_200.status_code = 200
mock_200.text = "OK"
with patch("src.plugins.translate._llm_async_http._get_http_client",
AsyncMock()) as mock_get:
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()):
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"},
@@ -330,14 +321,12 @@ class TestDoHttpRequest:
mock_200.status_code = 200
mock_200.text = "OK"
with patch("src.plugins.translate._llm_async_http._get_http_client",
AsyncMock()) as mock_get:
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:
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"},
@@ -355,14 +344,12 @@ class TestDoHttpRequest:
mock_429.headers = {}
mock_429.text = "Rate limited"
with patch("src.plugins.translate._llm_async_http._get_http_client",
AsyncMock()) as mock_get:
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()):
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"},
@@ -383,14 +370,12 @@ class TestDoHttpRequest:
mock_200.status_code = 200
mock_200.text = "OK"
with patch("src.plugins.translate._llm_async_http._get_http_client",
AsyncMock()) as mock_get:
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:
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"},
@@ -421,15 +406,17 @@ class TestHandleResponseFormatFallback:
payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}
with patch("src.plugins.translate._llm_async_http._get_http_client",
AsyncMock()) as mock_get:
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", {},
mock_400,
mock_400.text,
payload,
"https://api.openai.com",
{},
)
# Verify response_format was popped
assert "response_format" not in payload
@@ -446,14 +433,16 @@ class TestHandleResponseFormatFallback:
payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}
with patch("src.plugins.translate._llm_async_http._get_http_client",
AsyncMock()) as mock_get:
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", {},
mock_500,
mock_500.text,
payload,
"https://api.openai.com",
{},
)
# Payload unchanged
assert "response_format" in payload
@@ -470,15 +459,19 @@ class TestHandleResponseFormatFallback:
payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}
with patch("src.plugins.translate._llm_async_http._get_http_client",
AsyncMock()) as mock_get:
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", {},
mock_400,
mock_400.text,
payload,
"https://api.openai.com",
{},
)
assert "response_format" in payload
mock_client.post.assert_not_called()
# #endregion Test.LLMAsyncHttpClient