fix(translate): handle invalid LLM JSON responses

This commit is contained in:
2026-07-15 20:06:39 +03:00
parent 612cc55911
commit 30c8acf7ae
4 changed files with 101 additions and 14 deletions

View File

@@ -5,25 +5,25 @@
# @TEST_EDGE: no_choices -> ValueError
# @TEST_EDGE: empty_content -> ValueError
import json
import os
from pathlib import Path
import ssl
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
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,
call_openai_compatible,
get_shared_http_client,
)
from ss_tools.shared.ssl import httpx_verify, system_ssl_context
class TestGetVerify:
@@ -98,6 +98,33 @@ class TestCallOpenaiCompatible:
assert content == "Hello, world!"
assert finish_reason == "stop"
# #region test_invalid_json_response_raises_value_error [C:2] [TYPE Function]
# @BRIEF Verify an empty or non-JSON provider body becomes a diagnostic ValueError.
# @TEST_EDGE: invalid_provider_json -> ValueError with stable provider error
@pytest.mark.asyncio
async def test_invalid_json_response_raises_value_error(self):
"""A successful HTTP status with an invalid JSON body must not leak JSONDecodeError."""
mock_response = MagicMock(spec=httpx.Response)
mock_response.is_success = True
mock_response.status_code = 200
mock_response.headers = {"content-type": "text/html"}
mock_response.text = "<html>upstream unavailable</html>"
mock_response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
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="invalid JSON") as exc_info:
await call_openai_compatible(
"https://api.openai.com",
"sk-test",
"gpt-4o",
"translate this",
)
assert not isinstance(exc_info.value, json.JSONDecodeError)
assert "invalid JSON" in str(exc_info.value)
# #endregion test_invalid_json_response_raises_value_error
@pytest.mark.asyncio
async def test_no_choices_raises(self):
"""No choices in response raises ValueError."""

View File

@@ -6,16 +6,15 @@
# @TEST_EDGE: no_provider_id -> ValueError
# @TEST_EDGE: unsupported_provider -> ValueError
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, PropertyMock, patch
import sys
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
from src.models.translate import TranslationJob
from src.plugins.translate.preview_executor import PreviewExecutor
from .conftest import JOB_ID
class TestFetchSampleRows:
@@ -274,6 +273,38 @@ class TestCallLLM:
result = await executor.call_llm(job, "translate this", 4096)
assert result == "retried content"
# #region test_invalid_json_retry [C:2] [TYPE Function]
# @BRIEF Verify malformed provider JSON gets the same bounded preview retry as empty content.
# @TEST_EDGE: invalid_provider_json -> retry with doubled max_tokens
@pytest.mark.asyncio
async def test_invalid_json_retry(self, db_session):
"""Malformed provider JSON is retried once before the preview fails."""
executor = PreviewExecutor(db_session, MagicMock())
job = self._make_job()
with patch("src.services.llm_provider.LLMProviderService") as mock_svc:
mock_instance = MagicMock()
mock_svc.return_value = mock_instance
mock_instance.get_provider.return_value = self._make_provider()
mock_instance.get_decrypted_api_key.return_value = "sk-test"
with patch(
"src.plugins.translate.preview_executor.call_openai_compatible",
AsyncMock(
side_effect=[
ValueError("LLM provider returned an invalid JSON response"),
("retried content", "stop"),
]
),
) as mock_call:
result = await executor.call_llm(job, "translate this", 4096)
assert result == "retried content"
assert mock_call.call_count == 2
assert mock_call.call_args_list[0].kwargs["max_tokens"] == 4096
assert mock_call.call_args_list[1].kwargs["max_tokens"] == 8192
# #endregion test_invalid_json_retry
@pytest.mark.asyncio
async def test_empty_content_retry_exhausted(self, db_session):
"""If retry also returns empty content, raises."""