feat(translate): language detection, async HTTP LLM, history model, agent improvements
- Add async HTTP-based LLM transport (_llm_async_http.py) - Add orthogonal LLM call tests - Improve language detection (_lang_detect.py) and batch insert - Update translate schemas, service utils, preview constants/prompts - Add TranslateHistoryModel with pagination and filtering - Update agent confirmation, persistence, langgraph setup, run, tools - Improve LLM health checking in shared module - Update translate runs API, history route
This commit is contained in:
@@ -17,9 +17,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from src.plugins.translate._llm_async_http import (
|
||||
_get_verify,
|
||||
_get_http_client,
|
||||
from ss_tools.shared.ssl import httpx_verify, system_ssl_context
|
||||
from ss_tools.shared._llm_http import get_shared_http_client
|
||||
from ss_tools.shared._llm_http import (
|
||||
call_openai_compatible,
|
||||
_do_http_request,
|
||||
_handle_response_format_fallback,
|
||||
@@ -27,34 +27,37 @@ from src.plugins.translate._llm_async_http import (
|
||||
|
||||
|
||||
class TestGetVerify:
|
||||
"""_get_verify — SSL verification context."""
|
||||
"""SSL verification context (now from shared module)."""
|
||||
|
||||
def test_returns_ssl_context(self):
|
||||
"""Always returns SSLContext (no env-based disable)."""
|
||||
result = _get_verify()
|
||||
"""httpx_verify returns SSLContext (no env-based disable)."""
|
||||
result = httpx_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
def test_system_context_not_false(self):
|
||||
"""system_ssl_context never returns False."""
|
||||
result = system_ssl_context()
|
||||
assert result is not False
|
||||
|
||||
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()
|
||||
result = system_ssl_context()
|
||||
assert isinstance(result, ssl.SSLContext), "must not return False"
|
||||
|
||||
|
||||
class TestGetHttpClient:
|
||||
"""_get_http_client — module-level singleton."""
|
||||
"""get_shared_http_client — module-level singleton."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_client(self):
|
||||
def test_creates_client(self):
|
||||
"""Creates client on first call."""
|
||||
client = await _get_http_client()
|
||||
client = get_shared_http_client(timeout=180)
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reuses_client(self):
|
||||
def test_reuses_client(self):
|
||||
"""Returns same client on second call."""
|
||||
client1 = await _get_http_client()
|
||||
client2 = await _get_http_client()
|
||||
client1 = get_shared_http_client(timeout=180)
|
||||
client2 = get_shared_http_client(timeout=180)
|
||||
assert client1 is client2
|
||||
|
||||
|
||||
@@ -84,8 +87,8 @@ 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("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()):
|
||||
content, finish_reason = await call_openai_compatible(
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
@@ -104,8 +107,8 @@ 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("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(
|
||||
"https://api.openai.com",
|
||||
@@ -130,8 +133,8 @@ 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("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="empty content"):
|
||||
await call_openai_compatible(
|
||||
"https://api.openai.com",
|
||||
@@ -151,8 +154,8 @@ 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("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="LLM response processing failed"):
|
||||
await call_openai_compatible(
|
||||
"https://api.openai.com",
|
||||
@@ -177,8 +180,8 @@ 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("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(
|
||||
"https://api.openai.com",
|
||||
@@ -200,8 +203,8 @@ class TestCallOpenaiCompatible:
|
||||
)
|
||||
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("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(httpx.HTTPStatusError):
|
||||
await call_openai_compatible(
|
||||
"https://api.openai.com",
|
||||
@@ -222,8 +225,8 @@ 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("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()):
|
||||
# disable_reasoning=False, so response_format should be set
|
||||
content, _ = await call_openai_compatible(
|
||||
"https://api.openai.com",
|
||||
@@ -247,8 +250,8 @@ 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("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()):
|
||||
content, _ = await call_openai_compatible(
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
@@ -262,28 +265,29 @@ class TestCallOpenaiCompatible:
|
||||
class TestDoHttpRequest:
|
||||
"""_do_http_request — async HTTP POST with rate-limit retry."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(self):
|
||||
return AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success(self):
|
||||
async def test_success(self, mock_client):
|
||||
"""Single successful request."""
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = "OK"
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
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"
|
||||
response, text = await _do_http_request(
|
||||
mock_client,
|
||||
"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):
|
||||
async def test_rate_limit_retry(self, mock_client):
|
||||
"""429 triggers retry."""
|
||||
mock_429 = MagicMock(spec=httpx.Response)
|
||||
mock_429.status_code = 429
|
||||
@@ -294,23 +298,21 @@ 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:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
mock_client.post.side_effect = [mock_429, mock_200]
|
||||
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
|
||||
with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()):
|
||||
response, text = await _do_http_request(
|
||||
mock_client,
|
||||
"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):
|
||||
async def test_rate_limit_with_retry_after(self, mock_client):
|
||||
"""Retry-After header is respected."""
|
||||
mock_429 = MagicMock(spec=httpx.Response)
|
||||
mock_429.status_code = 429
|
||||
@@ -321,46 +323,41 @@ 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:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
mock_client.post.side_effect = [mock_429, mock_200]
|
||||
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)
|
||||
with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()) as mock_sleep:
|
||||
response, _ = await _do_http_request(
|
||||
mock_client,
|
||||
"https://api.openai.com/chat/completions",
|
||||
{"Authorization": "Bearer test"},
|
||||
{"model": "gpt-4o"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_sleep.assert_called_once_with(2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_exhausted(self):
|
||||
async def test_rate_limit_exhausted(self, mock_client):
|
||||
"""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
|
||||
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
|
||||
with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()):
|
||||
response, _ = await _do_http_request(
|
||||
mock_client,
|
||||
"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)."""
|
||||
async def test_rate_limit_invalid_retry_after(self, mock_client):
|
||||
"""Retry-After with non-int value falls back to exponential backoff."""
|
||||
mock_429 = MagicMock(spec=httpx.Response)
|
||||
mock_429.status_code = 429
|
||||
mock_429.headers = {"Retry-After": "abc"}
|
||||
@@ -370,27 +367,29 @@ 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:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
mock_client.post.side_effect = [mock_429, mock_200]
|
||||
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)
|
||||
with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()) as mock_sleep:
|
||||
response, _ = await _do_http_request(
|
||||
mock_client,
|
||||
"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.fixture
|
||||
def mock_client(self):
|
||||
return AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_400_response_format_error(self):
|
||||
async def test_400_response_format_error(self, mock_client):
|
||||
"""400 error with response_format pattern triggers retry."""
|
||||
mock_400 = MagicMock(spec=httpx.Response)
|
||||
mock_400.status_code = 400
|
||||
@@ -404,27 +403,25 @@ class TestHandleResponseFormatFallback:
|
||||
mock_200.encoding = "utf-8"
|
||||
mock_200.headers = {}
|
||||
|
||||
mock_client.post.return_value = mock_200
|
||||
|
||||
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
|
||||
await _handle_response_format_fallback(
|
||||
mock_client,
|
||||
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):
|
||||
async def test_non_400_noop(self, mock_client):
|
||||
"""Non-400 error does nothing."""
|
||||
mock_500 = MagicMock(spec=httpx.Response)
|
||||
mock_500.status_code = 500
|
||||
@@ -433,24 +430,21 @@ 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:
|
||||
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()
|
||||
await _handle_response_format_fallback(
|
||||
mock_client,
|
||||
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):
|
||||
async def test_400_no_pattern_noop(self, mock_client):
|
||||
"""400 error without response_format pattern does nothing."""
|
||||
mock_400 = MagicMock(spec=httpx.Response)
|
||||
mock_400.status_code = 400
|
||||
@@ -459,19 +453,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:
|
||||
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()
|
||||
await _handle_response_format_fallback(
|
||||
mock_client,
|
||||
mock_400,
|
||||
mock_400.text,
|
||||
payload,
|
||||
"https://api.openai.com",
|
||||
{},
|
||||
)
|
||||
assert "response_format" in payload
|
||||
mock_client.post.assert_not_called()
|
||||
|
||||
|
||||
# #endregion Test.LLMAsyncHttpClient
|
||||
|
||||
Reference in New Issue
Block a user