Enhancement: support passthrough extra params for LLMs (#14202)

This commit is contained in:
shamoon
2026-09-23 18:20:43 +00:00
committed by GitHub
parent 1b277dd8e1
commit 04297fd02c
6 changed files with 115 additions and 0 deletions
+13
View File
@@ -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=<json>`](#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=<cron expression>`](#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
+3
View File
@@ -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:
+21
View File
@@ -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()
@@ -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()
+2
View File
@@ -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,
)
+33
View File
@@ -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,