Enhancement: support passthrough extra params for LLMs

This commit is contained in:
shamoon
2026-09-20 21:09:20 -07:00
parent 8705bd510a
commit 285e3fec39
6 changed files with 127 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
+2
View File
@@ -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:
+20
View File
@@ -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()
@@ -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()
+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,
)
+25
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,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,