diff --git a/docs/configuration.md b/docs/configuration.md index d5ea6e96c..f48291824 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2217,6 +2217,19 @@ used with the OpenAI-compatible backend to target a custom provider or local gat Defaults to true, which allows internal endpoints. +#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS} + +: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not +set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the +backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning +models refuse tool calls on the chat completions API unless reasoning is off: + + ``` + PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"} + ``` + + Defaults to empty, which adds nothing to requests. + #### [`PAPERLESS_LLM_INDEX_TASK_CRON=`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON} : Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if diff --git a/src/paperless/config.py b/src/paperless/config.py index aabdfabd0..41af5b6c8 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -254,6 +254,7 @@ class AIConfig(BaseConfig): llm_endpoint: str = dataclasses.field(init=False) llm_output_language: str = dataclasses.field(init=False) llm_allow_internal_endpoints: bool = dataclasses.field(init=False) + llm_extra_params: dict = dataclasses.field(init=False) def __post_init__(self) -> None: app_config = self._get_config_instance() @@ -287,6 +288,7 @@ class AIConfig(BaseConfig): app_config.llm_output_language or settings.LLM_OUTPUT_LANGUAGE ) self.llm_allow_internal_endpoints = settings.LLM_ALLOW_INTERNAL_ENDPOINTS + self.llm_extra_params = settings.LLM_EXTRA_PARAMS @property def llm_index_enabled(self) -> bool: diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 01cf2380b..c5b7b3d72 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -1081,6 +1081,25 @@ CLASSIFIER_LANGUAGES: Final[dict[str, str]] = { } +def _get_llm_extra_params() -> dict: + """ + Parse PAPERLESS_AI_LLM_EXTRA_PARAMS, a JSON object passed straight through + to the LLM backend's request body. + """ + raw = os.getenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", "{}") + try: + parsed = json.loads(raw) + except json.JSONDecodeError as e: + raise ImproperlyConfigured( + "PAPERLESS_AI_LLM_EXTRA_PARAMS must be valid JSON", + ) from e + if not isinstance(parsed, dict): + raise ImproperlyConfigured( + "PAPERLESS_AI_LLM_EXTRA_PARAMS must be a JSON object", + ) + return parsed + + def _get_classifier_language_setting(ocr_lang: str) -> str | None: """ Maps the primary Tesseract language to the classifier's stemming @@ -1241,3 +1260,4 @@ LLM_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env( "PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS", "true", ) +LLM_EXTRA_PARAMS = _get_llm_extra_params() diff --git a/src/paperless/tests/settings/test_settings.py b/src/paperless/tests/settings/test_settings.py index 701f203f1..36761a83f 100644 --- a/src/paperless/tests/settings/test_settings.py +++ b/src/paperless/tests/settings/test_settings.py @@ -7,6 +7,7 @@ from django.core.exceptions import ImproperlyConfigured from paperless.settings import _get_allauth_trusted_proxy_count from paperless.settings import _get_classifier_language_setting +from paperless.settings import _get_llm_extra_params from paperless.settings import _get_search_language_setting from paperless.settings import _parse_paperless_url from paperless.settings import default_threads_per_worker @@ -166,3 +167,67 @@ class TestPaperlessURLSettings(TestCase): self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS) self.assertIn(url, settings.CORS_ALLOWED_ORIGINS) + + +class TestLlmExtraParams(TestCase): + def test_unset_is_empty(self) -> None: + """ + GIVEN: + - No extra LLM params configured + WHEN: + - The setting is parsed + THEN: + - An empty dict is returned, so nothing is added to requests + """ + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(_get_llm_extra_params(), {}) + + def test_parses_json_object(self) -> None: + """ + GIVEN: + - A JSON object of provider parameters + WHEN: + - The setting is parsed + THEN: + - It is returned as a dict + """ + with mock.patch.dict( + os.environ, + {"PAPERLESS_AI_LLM_EXTRA_PARAMS": '{"reasoning_effort": "none"}'}, + ): + self.assertEqual( + _get_llm_extra_params(), + {"reasoning_effort": "none"}, + ) + + def test_invalid_json_raises(self) -> None: + """ + GIVEN: + - A value which is not valid JSON + WHEN: + - The setting is parsed + THEN: + - Startup fails with a clear error instead of being ignored + """ + with mock.patch.dict( + os.environ, + {"PAPERLESS_AI_LLM_EXTRA_PARAMS": "reasoning_effort=none"}, + ): + with pytest.raises(ImproperlyConfigured, match="valid JSON"): + _get_llm_extra_params() + + def test_non_object_raises(self) -> None: + """ + GIVEN: + - Valid JSON which is not an object + WHEN: + - The setting is parsed + THEN: + - Startup fails with a clear error + """ + with mock.patch.dict( + os.environ, + {"PAPERLESS_AI_LLM_EXTRA_PARAMS": '["none"]'}, + ): + with pytest.raises(ImproperlyConfigured, match="JSON object"): + _get_llm_extra_params() diff --git a/src/paperless_ai/client.py b/src/paperless_ai/client.py index 682a0e86c..cb4afe10e 100644 --- a/src/paperless_ai/client.py +++ b/src/paperless_ai/client.py @@ -75,6 +75,7 @@ class AIClient: context_window=self.settings.llm_context_size, request_timeout=self.settings.llm_request_timeout, system_prompt=LLM_SYSTEM_PROMPT, + additional_kwargs=self.settings.llm_extra_params, client=Client( host=endpoint, timeout=self.settings.llm_request_timeout, @@ -111,6 +112,7 @@ class AIClient: is_chat_model=True, is_function_calling_model=True, system_prompt=LLM_SYSTEM_PROMPT, + additional_kwargs=self.settings.llm_extra_params, http_client=http_client, async_http_client=async_http_client, ) diff --git a/src/paperless_ai/tests/test_client.py b/src/paperless_ai/tests/test_client.py index 79bb6ad44..94ed13d93 100644 --- a/src/paperless_ai/tests/test_client.py +++ b/src/paperless_ai/tests/test_client.py @@ -23,6 +23,7 @@ def mock_ai_config(): mock_config.llm_allow_internal_endpoints = True mock_config.llm_context_size = 8192 mock_config.llm_request_timeout = 120 + mock_config.llm_extra_params = {} MockAIConfig.return_value = mock_config yield mock_config @@ -52,6 +53,7 @@ def test_get_llm_ollama(mock_ai_config, mock_ollama_llm): context_window=8192, request_timeout=120, system_prompt=LLM_SYSTEM_PROMPT, + additional_kwargs={}, client=ANY, async_client=ANY, ) @@ -74,6 +76,7 @@ def test_get_llm_openai(mock_ai_config, mock_openai_llm): is_chat_model=True, is_function_calling_model=True, system_prompt=LLM_SYSTEM_PROMPT, + additional_kwargs={}, http_client=ANY, async_http_client=ANY, ) @@ -196,6 +199,28 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm): ) +def test_get_llm_passes_extra_params(mock_ai_config, mock_openai_llm): + """ + GIVEN: + - Extra LLM params configured, e.g. for a provider that needs a + parameter we do not set ourselves + WHEN: + - The client builds the LLM + THEN: + - They are handed to the backend as additional_kwargs + """ + mock_ai_config.llm_backend = "openai-like" + mock_ai_config.llm_model = "gpt-5.6-luna" + mock_ai_config.llm_endpoint = "http://test-url" + mock_ai_config.llm_extra_params = {"reasoning_effort": "none"} + + AIClient() + + assert mock_openai_llm.call_args.kwargs["additional_kwargs"] == { + "reasoning_effort": "none", + } + + def test_run_llm_query_openai_timeout_raises_local_error( mock_ai_config, mock_openai_llm,