Files
ss-tools/backend/tests/plugins/translate/test_llm_async_http.py
busya 8fd23f7ea1 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
2026-07-06 21:00:28 +03:00

478 lines
19 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 ssl
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
from 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_returns_ssl_context(self):
"""Always returns SSLContext (no env-based disable)."""
result = _get_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."""
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}):
result = _get_verify()
assert isinstance(result, ssl.SSLContext), "must not return 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