Automation you can leave alone. This release is almost entirely about paperless-gpt doing what its logs already claimed it was doing — tags that were computed and then silently dropped, retry loops that never concluded, and settings that quietly had no effect.
The theme is follow-through: if the app says it added a tag, the tag is there; if a document can't be processed, it stops being retried and says so; if you set an option, it reaches the model.
Heads-up — two behaviour changes worth reading before you upgrade. See Notable changes at the bottom.
🏷️ Tags that actually get applied
Five separate defects conspired to make completion tags vanish between the log line and paperless-ngx. Every one of them is fixed.
AUTO_TAG_COMPLETEis applied instead of being dropped. The tag was written to anAddTagsfield that nothing ever read, so it was computed, logged as "adding", and discarded. A second tag path — taken when no other field changed, which is the likeliest case for an already-tidy document — ignored it too. Thanks @skort-90 for the four-document reproduction and @Denim5660 for the original root-cause analysis. (#1059, closes #1006, #1074)PDF_OCR_COMPLETE_TAGis created at startup, likeFAIL_TAGalready was. Without it, the name→ID resolution silently skipped a tag that didn't exist yet — which is why this worked for some people and not others, with nothing in the log to explain the difference. (#1064, closes #854)- Tag names now resolve case-insensitively. The rest of the tag handling already ignored case; the name→ID lookup was the odd one out, so a configured
Paperless-GPT-Auto-Completecouldn't find a storedpaperless-gpt-auto-complete. Where a database holds several case variants, the original (lowest id) wins deterministically and the ambiguity is logged. Thanks @ivanzud! (#1075) - An added tag now wins a name collision with a removed one. Configuring the completion tag to the same name as the trigger tag used to leave the document with neither. (#1059, closes #458)
- paperless-gpt's own tags are never offered to the LLM.
FAIL_TAG,AUTO_TAG_COMPLETEandPDF_OCR_COMPLETE_TAGwere missing from the exclusion list, so the model suggested them as ordinary document tags. With an OCR→tagging workflow that produced an unbounded loop, re-billing every LLM call on each pass. Thanks @hakehardware and @fr0der1c for reporting it, twice. (#1061, closes #877, #1015)
🔁 Loops that end
Three ways the auto pipeline can fail. Until now, only two of them could stop.
- Repeated suggestion failures now break the loop. A permanent LLM error — a prompt that can't fit the context, a model that no longer exists — used to re-bill the same request forever: one report measured ~12,000 rejected requests over seven days. Worse, because the poll fetches a single unordered page of 25, enough stuck documents starved everything behind them (67 documents, with the container still reporting healthy). After
AUTO_TAG_MAX_RETRIESattempts the auto tag comes off andFAIL_TAGgoes on. Thanks @JoGres-DE for an exceptionally precise report. (#1076, closes #1071)
This should also end the "worker goes permanently silent" reports: the poll loop doubles its backoff on every failing cycle, capped at one hour, so a single permanently failing document kept the whole worker in hour-long sleeps until a container restart. With the document leaving the queue, the cycle succeeds and the backoff resets. (likely #1016) - Ollama requests can no longer hang forever. Both Ollama paths ran on a timeout-less HTTP client, so a single stalled generation blocked the background loop indefinitely — recoverable only by restarting the container.
OLLAMA_TIMEOUT_SECONDS(default 300s) bounds it. Thanks @swiception! (#1070)
Note this covers the Ollama paths only — other providers still share an unbounded HTTP client, so #937 stays open.
🧠 Ollama: OLLAMA_THINK finally does something
OLLAMA_THINK had no effect at all, in either direction. The underlying library nested think inside the request's options object, where the Ollama server doesn't look for it, and dropped the value entirely when it was false.
On thinking-capable models (Qwen 3 / 3.5, Gemma 3) that meant unconditional reasoning: the model spent its output budget on a reasoning trace and returned results that were glacial or empty — worst exactly where it matters most, on closed-list classification and strict JSON.
The metadata path now uses Ollama's official client, where think is a top-level field. Reasoning levels (low / medium / high) work too. Thanks @lunetics for the diagnosis and the implementation, and @andglaser for the independent report. (#1063, closes #1024, #1056)
The same change brings LLM_TEMPERATURE for the text LLM (closes #1033), plus LLM_MAX_TOKENS and OLLAMA_KEEP_ALIVE. Ollama-only variables now warn instead of being silently inert when another provider is configured, and OLLAMA_HEADERS is treated as a secret so credential-bearing headers stay out of the configuration view.
🔬 OCR
OCR_LIMIT_PAGES=0means all pages again — an explicit zero was being mistaken for "unset" and silently replaced by the 5-page default. Thanks @CBOSSX! (#1047, closes #1037)- Transient provider errors are retried per page (HTTP 429/5xx, exponential backoff) instead of forfeiting the whole document. Deliberately more patient than the suggestion path: a failed page throws away every page before it. Thanks @MarcvsTvllivs! (#1004)
- Uploaded files are deleted from Mistral after OCR. They were being left in Mistral's cloud storage indefinitely — a genuine data-retention problem for a self-hosted document tool, and invisible from the outside. Thanks @lunetics! (#1007)
- Oversized page images compress correctly. After stepping quality down to fit a size limit, the last-resort resize re-encoded at a higher quality than it had just settled on, partly undoing the reduction. Thanks @maksyms! (#946)
image_limitandimage_min_sizeare configurable for Mistral OCR — raise the minimum to stop small boxed fields (handwritten form entries) being skipped as images. Thanks @jipe-b! (#1050, closes #1048)
🔌 Using a different AI provider
Five separate requests — issues and pull requests — asked for providers that already shipped, one of them implementing a whole provider branch that did nothing OPENAI_BASE_URL didn't already do. That was a documentation failure on our side, not missing features.
OpenAI-compatible providers is a new guide with copy-pasteable configuration for OpenRouter, LM Studio, vLLM, LiteLLM, llama.cpp and Azure — plus the three things everyone gets wrong (a base URL missing /v1, vendor-specific model names, local servers rejecting an empty API key) and a troubleshooting section mapping the recurring errors to their causes. (#1060, closes #864, #908, #1041)
Anything that documents an "OpenAI-compatible endpoint" works today via
LLM_PROVIDER=openaiplusOPENAI_BASE_URL. No new release required.
⚙️ New options
| Variable | What it does |
|---|---|
AUTO_TAG_MAX_RETRIES
| Give up on a document after N failed suggestion attempts (default 3, 0 retries forever)
|
OLLAMA_TIMEOUT_SECONDS
| Per-request timeout for Ollama (default 300)
|
PRESERVE_EXISTING_METADATA
| Keep a correspondent or document type that is already set, so paperless-ngx' own classifier or a manual correction stays in charge. Thanks @keefar! (#1065, closes #1032) |
CORRESPONDENT_PROMPT_LIMIT
| Cap how many correspondents go into the prompt. On a 13k-document instance with 628 correspondents, the correspondent step went from a >10-minute timeout per document to seconds. Thanks @interruptor! (#1043) |
LLM_TEMPERATURE, LLM_MAX_TOKENS, OLLAMA_KEEP_ALIVE
| Ollama metadata generation |
MISTRAL_OCR_IMAGE_LIMIT, MISTRAL_OCR_IMAGE_MIN_SIZE
| Mistral OCR image extraction |
🔒 Privacy & deployment
- Document content is no longer logged at info level. Full document text was landing in
docker logsand any log shipper. Thanks @mrab54! (#922, closes #921) - Non-root containers start correctly — Kubernetes
runAsNonRootanddocker run --usernow skip the privilege-drop path instead of failing. Thanks @vistalba! (#1002)
📦 Under the hood
- The end-to-end test suite works against paperless-ngx 3.x again (it refuses to start on the documented placeholder secret key, so the container died during init) and is pinned by digest instead of tracking
:latest. Extracted from @lunetics' work in #1058. (#1062) - Container images are assembled without a redundant cross-registry copy, which was exhausting Docker Hub pull quota on busy days. (#1068)
- Dependencies refreshed, including Node 24 and Alpine 3.24.
⚠️ Notable changes
1. Documents whose suggestions keep failing now leave the queue. Previously they were retried forever. After AUTO_TAG_MAX_RETRIES attempts (default 3) the auto tag is removed and FAIL_TAG applied. If you relied on indefinite retries, set AUTO_TAG_MAX_RETRIES=0. This mirrors what OCR_MAX_RETRIES already did for OCR in v0.27.0.
2. Ollama requests now time out after 300 seconds. Previously they could hang indefinitely. If you run very large models on slow hardware and a legitimate generation exceeds five minutes, raise OLLAMA_TIMEOUT_SECONDS or set it to 0 to restore the old behaviour.
Both defaults were chosen so the failure mode is "this document is marked for review" rather than "the worker is silently wedged".
🙏 Credits
This release came almost entirely from the community — reports, diagnoses and code.
- @lunetics (Matthias Breddin) — the
OLLAMA_THINKdiagnosis and native-client implementation (#1063), the Mistral data-retention fix (#1007), and the E2E fixes that unblocked CI for every open PR (#1062) - @swiception — the Ollama request timeout (#1070)
- @ivanzud (Ivan) — case-insensitive tag resolution, including the determinism argument that stopped it being subtly wrong (#1075)
- @MarcvsTvllivs — per-page OCR retry with backoff (#1004)
- @interruptor —
CORRESPONDENT_PROMPT_LIMIT, with measurements (#1043) - @keefar —
PRESERVE_EXISTING_METADATA(#1065) - @CBOSSX — the explicit-zero page limit fix (#1047)
- @jipe-b — Mistral image parameters (#1050)
- @maksyms — the image re-encoding fix (#946)
- @vistalba — non-root container support (#1002)
- @mrab54 — content logging at debug level (#922)
- @JoGres-DE, @skort-90, @Denim5660, @hakehardware, @fr0der1c, @andglaser, @nmeden, @vanderfran, @embdMan, @jacobhausler, @Christoph274 — reports precise enough to fix from, several with the root cause already correctly identified
Full Changelog: v0.27.0...v0.28.0