fix(translate): handle invalid LLM JSON responses
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user