From 04297fd02c3d3b6a3f13e210a49e3f4c3c4009b3 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:20:43 -0700 Subject: [PATCH] Enhancement: support passthrough extra params for LLMs (#14202) --- docs/configuration.md | 13 ++++++ src/paperless/config.py | 3 ++ src/paperless/settings/__init__.py | 21 +++++++++ src/paperless/tests/settings/test_settings.py | 43 +++++++++++++++++++ src/paperless_ai/client.py | 2 + src/paperless_ai/tests/test_client.py | 33 ++++++++++++++ 6 files changed, 115 insertions(+) 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..5506d1fbd 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -1,5 +1,6 @@ import dataclasses import json +from typing import Any from django.conf import settings @@ -254,6 +255,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[str, Any] = dataclasses.field(init=False) def __post_init__(self) -> None: app_config = self._get_config_instance() @@ -287,6 +289,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..a9a57a6b3 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -7,6 +7,7 @@ import multiprocessing import os import tempfile from pathlib import Path +from typing import Any from typing import Final from urllib.parse import urlparse @@ -1081,6 +1082,25 @@ CLASSIFIER_LANGUAGES: Final[dict[str, str]] = { } +def _get_llm_extra_params() -> dict[str, Any]: + """ + 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 +1261,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..95155aaef 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,45 @@ class TestPaperlessURLSettings(TestCase): self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS) self.assertIn(url, settings.CORS_ALLOWED_ORIGINS) + + +class TestLlmExtraParams: + @pytest.mark.parametrize( + ("env_value", "expected"), + [ + pytest.param(None, {}, id="unset"), + pytest.param( + '{"reasoning_effort": "none"}', + {"reasoning_effort": "none"}, + id="json-object", + ), + ], + ) + def test_parses( + self, + monkeypatch, + env_value, + expected, + ): + if env_value is None: + monkeypatch.delenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", raising=False) + else: + monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value) + assert _get_llm_extra_params() == expected + + @pytest.mark.parametrize( + ("env_value", "match"), + [ + pytest.param("reasoning_effort=none", "valid JSON", id="invalid-json"), + pytest.param('["none"]', "JSON object", id="not-an-object"), + ], + ) + def test_invalid_raises( + self, + monkeypatch, + env_value, + match, + ): + monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value) + with pytest.raises(ImproperlyConfigured, match=match): + _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..4a908b867 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,36 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm): ) +@pytest.mark.parametrize( + ("backend", "llm_fixture"), + [ + pytest.param("openai-like", "mock_openai_llm", id="openai-like"), + pytest.param("ollama", "mock_ollama_llm", id="ollama"), + ], +) +def test_get_llm_passes_extra_params(request, mock_ai_config, backend, llm_fixture): + """ + 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 + """ + llm_mock = request.getfixturevalue(llm_fixture) + mock_ai_config.llm_backend = backend + 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 llm_mock.call_args.kwargs["additional_kwargs"] == { + "reasoning_effort": "none", + } + + def test_run_llm_query_openai_timeout_raises_local_error( mock_ai_config, mock_openai_llm,