mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-21 08:58:31 +00:00
Enhancement: support passthrough extra params for LLMs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user