diff --git a/src/documents/tests/test_views.py b/src/documents/tests/test_views.py index 282203866..7e01b273d 100644 --- a/src/documents/tests/test_views.py +++ b/src/documents/tests/test_views.py @@ -32,6 +32,7 @@ from documents.signals.handlers import update_llm_suggestions_cache from documents.tests.utils import DirectoriesMixin from documents.tests.utils import read_streaming_response from paperless.models import ApplicationConfiguration +from paperless_ai.exceptions import LLMProviderError from paperless_ai.exceptions import LLMTimeoutError @@ -737,6 +738,38 @@ class TestAISuggestions(DirectoriesMixin, TestCase): get_llm_suggestion_cache(self.document.pk, backend="openai-like"), ) + @patch("documents.views.get_ai_document_classification") + @override_settings( + AI_ENABLED=True, + LLM_BACKEND="openai-like", + ) + def test_ai_suggestions_with_llm_provider_error( + self, + mock_get_ai_classification, + ) -> None: + mock_get_ai_classification.side_effect = LLMProviderError( + "confidential provider response", + ) + + self.client.force_login(user=self.user) + response = self.client.get( + f"/api/documents/{self.document.pk}/ai_suggestions/", + ) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + self.assertEqual( + response.json(), + { + "ai": [ + "AI backend rejected the request. Check logs for details.", + ], + }, + ) + self.assertNotIn("confidential provider response", response.content.decode()) + self.assertIsNone( + get_llm_suggestion_cache(self.document.pk, backend="openai-like"), + ) + @patch("documents.views.get_ai_document_classification") @override_settings( AI_ENABLED=True, diff --git a/src/documents/views.py b/src/documents/views.py index 4cf775bd6..2a2119f7e 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -251,6 +251,7 @@ from paperless.views import StandardPagination from paperless_ai.ai_classifier import get_ai_document_classification from paperless_ai.ai_classifier import get_llm_output_language from paperless_ai.chat import stream_chat_with_documents +from paperless_ai.exceptions import LLMProviderError from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.matching import extract_unmatched_names from paperless_ai.matching import match_correspondents_by_name @@ -1602,6 +1603,22 @@ class DocumentViewSet( {"ai": [_("AI backend request timed out.")]}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) + except LLMProviderError: + logger.exception( + "AI backend rejected the request for document %s", + doc.pk, + ) + return Response( + { + "ai": [ + _( + "AI backend rejected the request. " + "Check logs for details.", + ), + ], + }, + status=status.HTTP_502_BAD_GATEWAY, + ) set_llm_suggestions_cache( doc.pk, llm_suggestions, diff --git a/src/paperless_ai/client.py b/src/paperless_ai/client.py index b14f4a726..64a3be45c 100644 --- a/src/paperless_ai/client.py +++ b/src/paperless_ai/client.py @@ -22,6 +22,7 @@ from paperless.network import validate_outbound_http_url from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import DocumentClassifierSchema from paperless_ai.base_model import model_to_classification_suggestions +from paperless_ai.exceptions import LLMProviderError from paperless_ai.exceptions import LLMTimeoutError logger = logging.getLogger("paperless_ai.client") @@ -132,7 +133,7 @@ class AIClient: from llama_index.core.llms import ChatMessage if self.settings.llm_backend == LLMBackend.OLLAMA: - with self._normalize_timeouts(): + with self._normalize_errors(): result = self.llm.chat( [ChatMessage(role="user", content=prompt)], format=DocumentClassifierSchema.model_json_schema(), @@ -153,7 +154,7 @@ class AIClient: content=f"{prompt}\n\n" f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.", ) - with self._normalize_timeouts(): + with self._normalize_errors(): result = self.llm.chat_with_tools( tools=[tool], user_msg=user_msg, @@ -173,7 +174,7 @@ class AIClient: ) @contextmanager - def _normalize_timeouts(self) -> Iterator[None]: + def _normalize_errors(self) -> Iterator[None]: try: yield except httpx.TimeoutException as exc: @@ -181,8 +182,18 @@ class AIClient: except Exception as exc: if self._is_openai_timeout(exc): raise LLMTimeoutError from exc + if self._is_openai_status_error(exc): + raise LLMProviderError from exc raise + def _is_openai_status_error(self, exc: Exception) -> bool: + if self.settings.llm_backend != LLMBackend.OPENAI_LIKE: + return False + + from openai import APIStatusError + + return isinstance(exc, APIStatusError) + def _is_openai_timeout(self, exc: Exception) -> bool: if self.settings.llm_backend != LLMBackend.OPENAI_LIKE: return False diff --git a/src/paperless_ai/exceptions.py b/src/paperless_ai/exceptions.py index 48ac1db5d..ab3415e85 100644 --- a/src/paperless_ai/exceptions.py +++ b/src/paperless_ai/exceptions.py @@ -1,2 +1,6 @@ class LLMTimeoutError(Exception): pass + + +class LLMProviderError(Exception): + """The LLM backend rejected the request.""" diff --git a/src/paperless_ai/tests/test_client.py b/src/paperless_ai/tests/test_client.py index 7568e90b1..8d6c52c29 100644 --- a/src/paperless_ai/tests/test_client.py +++ b/src/paperless_ai/tests/test_client.py @@ -11,6 +11,7 @@ from llama_index.core.llms.llm import ToolSelection from paperless_ai.client import LLM_SYSTEM_PROMPT from paperless_ai.client import PLACEHOLDER_API_KEY from paperless_ai.client import AIClient +from paperless_ai.exceptions import LLMProviderError from paperless_ai.exceptions import LLMTimeoutError @@ -214,6 +215,30 @@ def test_run_llm_query_openai_timeout_raises_local_error( client.run_llm_query("test_prompt") +def test_run_llm_query_openai_status_error_raises_provider_error( + mock_ai_config, + mock_openai_llm, +): + mock_ai_config.llm_backend = "openai-like" + mock_ai_config.llm_model = "test_model" + mock_ai_config.llm_endpoint = "http://test-url" + + request = httpx.Request("POST", "http://test-url/v1/chat/completions") + body = {"error": {"message": "Thinking mode does not support this tool_choice"}} + mock_openai_llm.return_value.chat_with_tools.side_effect = openai.BadRequestError( + "Error code: 400", + response=httpx.Response(400, request=request, json=body), + body=body, + ) + + client = AIClient() + + with pytest.raises(LLMProviderError) as exc_info: + client.run_llm_query("test_prompt") + assert str(exc_info.value) == "" + assert isinstance(exc_info.value.__cause__, openai.BadRequestError) + + def test_run_llm_query_httpx_timeout_raises_local_error( mock_ai_config, mock_ollama_llm,