mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-05-01 12:29:25 +00:00
Enhancement (dev): Use OpenAI-like backend (#12668)
This commit is contained in:
@@ -218,7 +218,8 @@ def set_llm_suggestions_cache(
|
||||
timeout: int = CACHE_50_MINUTES,
|
||||
) -> None:
|
||||
"""
|
||||
Cache LLM-generated suggestions using a backend-specific identifier (e.g. 'openai:gpt-4').
|
||||
Cache LLM-generated suggestions using a backend-specific identifier
|
||||
(e.g. 'openai-like:gpt-4').
|
||||
"""
|
||||
doc_key = get_suggestion_cache_key(document_id)
|
||||
cache.set(
|
||||
|
||||
@@ -848,7 +848,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
json.dumps(
|
||||
{
|
||||
"ai_enabled": True,
|
||||
"llm_embedding_backend": "openai",
|
||||
"llm_embedding_backend": "openai-like",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
|
||||
@@ -404,7 +404,7 @@ class TestSystemStatus(APITestCase):
|
||||
THEN:
|
||||
- The response contains the correct AI status
|
||||
"""
|
||||
with override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="openai"):
|
||||
with override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="openai-like"):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# No tasks found
|
||||
@@ -431,7 +431,7 @@ class TestSystemStatus(APITestCase):
|
||||
THEN:
|
||||
- The response contains the correct AI status
|
||||
"""
|
||||
with override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="openai"):
|
||||
with override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="openai-like"):
|
||||
PaperlessTaskFactory(
|
||||
task_type=PaperlessTask.TaskType.LLM_INDEX,
|
||||
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
|
||||
|
||||
@@ -359,7 +359,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="openai",
|
||||
LLM_BACKEND="openai-like",
|
||||
)
|
||||
def test_suggestions_with_invalid_ai_configuration(
|
||||
self,
|
||||
@@ -379,7 +379,9 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
"ai": ["Invalid AI configuration."],
|
||||
},
|
||||
)
|
||||
self.assertIsNone(get_llm_suggestion_cache(self.document.pk, backend="openai"))
|
||||
self.assertIsNone(
|
||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||
)
|
||||
|
||||
def test_invalidate_suggestions_cache(self) -> None:
|
||||
self.client.force_login(user=self.user)
|
||||
|
||||
@@ -34,7 +34,7 @@ class Migration(migrations.Migration):
|
||||
name="llm_backend",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[("openai", "OpenAI"), ("ollama", "Ollama")],
|
||||
choices=[("openai-like", "OpenAI-compatible"), ("ollama", "Ollama")],
|
||||
max_length=128,
|
||||
null=True,
|
||||
verbose_name="Sets the LLM backend",
|
||||
@@ -45,7 +45,10 @@ class Migration(migrations.Migration):
|
||||
name="llm_embedding_backend",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[("openai", "OpenAI"), ("huggingface", "Huggingface")],
|
||||
choices=[
|
||||
("openai-like", "OpenAI-compatible"),
|
||||
("huggingface", "Huggingface"),
|
||||
],
|
||||
max_length=128,
|
||||
null=True,
|
||||
verbose_name="Sets the LLM embedding backend",
|
||||
|
||||
@@ -75,7 +75,7 @@ class ColorConvertChoices(models.TextChoices):
|
||||
|
||||
|
||||
class LLMEmbeddingBackend(models.TextChoices):
|
||||
OPENAI = ("openai", _("OpenAI"))
|
||||
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
|
||||
HUGGINGFACE = ("huggingface", _("Huggingface"))
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class LLMBackend(models.TextChoices):
|
||||
Matches to --llm-backend
|
||||
"""
|
||||
|
||||
OPENAI = ("openai", _("OpenAI"))
|
||||
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
|
||||
OLLAMA = ("ollama", _("Ollama"))
|
||||
|
||||
|
||||
|
||||
@@ -1174,9 +1174,9 @@ REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT")
|
||||
AI_ENABLED = get_bool_from_env("PAPERLESS_AI_ENABLED", "NO")
|
||||
LLM_EMBEDDING_BACKEND = os.getenv(
|
||||
"PAPERLESS_AI_LLM_EMBEDDING_BACKEND",
|
||||
) # "huggingface" or "openai"
|
||||
) # "huggingface" or "openai-like"
|
||||
LLM_EMBEDDING_MODEL = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_MODEL")
|
||||
LLM_BACKEND = os.getenv("PAPERLESS_AI_LLM_BACKEND") # "ollama" or "openai"
|
||||
LLM_BACKEND = os.getenv("PAPERLESS_AI_LLM_BACKEND") # "ollama" or "openai-like"
|
||||
LLM_MODEL = os.getenv("PAPERLESS_AI_LLM_MODEL")
|
||||
LLM_API_KEY = os.getenv("PAPERLESS_AI_LLM_API_KEY")
|
||||
LLM_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_ENDPOINT")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from paperless.models import LLMBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from llama_index.core.llms import ChatMessage
|
||||
from llama_index.llms.ollama import Ollama
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from llama_index.llms.openai_like import OpenAILike
|
||||
|
||||
from paperless.config import AIConfig
|
||||
from paperless.network import validate_outbound_http_url
|
||||
@@ -22,8 +24,8 @@ class AIClient:
|
||||
self.settings = AIConfig()
|
||||
self.llm = self.get_llm()
|
||||
|
||||
def get_llm(self) -> "Ollama | OpenAI":
|
||||
if self.settings.llm_backend == "ollama":
|
||||
def get_llm(self) -> "Ollama | OpenAILike":
|
||||
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
||||
from llama_index.llms.ollama import Ollama
|
||||
|
||||
endpoint = self.settings.llm_endpoint or "http://localhost:11434"
|
||||
@@ -36,8 +38,8 @@ class AIClient:
|
||||
base_url=endpoint,
|
||||
request_timeout=120,
|
||||
)
|
||||
elif self.settings.llm_backend == "openai":
|
||||
from llama_index.llms.openai import OpenAI
|
||||
elif self.settings.llm_backend == LLMBackend.OPENAI_LIKE:
|
||||
from llama_index.llms.openai_like import OpenAILike
|
||||
|
||||
endpoint = self.settings.llm_endpoint or None
|
||||
if endpoint:
|
||||
@@ -45,10 +47,12 @@ class AIClient:
|
||||
endpoint,
|
||||
allow_internal=self.settings.llm_allow_internal_endpoints,
|
||||
)
|
||||
return OpenAI(
|
||||
return OpenAILike(
|
||||
model=self.settings.llm_model or "gpt-3.5-turbo",
|
||||
api_base=endpoint,
|
||||
api_key=self.settings.llm_api_key,
|
||||
is_chat_model=True,
|
||||
is_function_calling_model=True,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported LLM backend: {self.settings.llm_backend}")
|
||||
|
||||
@@ -19,8 +19,8 @@ def get_embedding_model() -> "BaseEmbedding":
|
||||
config = AIConfig()
|
||||
|
||||
match config.llm_embedding_backend:
|
||||
case LLMEmbeddingBackend.OPENAI:
|
||||
from llama_index.embeddings.openai import OpenAIEmbedding
|
||||
case LLMEmbeddingBackend.OPENAI_LIKE:
|
||||
from llama_index.embeddings.openai_like import OpenAILikeEmbedding
|
||||
|
||||
endpoint = config.llm_endpoint or None
|
||||
if endpoint:
|
||||
@@ -28,8 +28,8 @@ def get_embedding_model() -> "BaseEmbedding":
|
||||
endpoint,
|
||||
allow_internal=config.llm_allow_internal_endpoints,
|
||||
)
|
||||
return OpenAIEmbedding(
|
||||
model=config.llm_embedding_model or "text-embedding-3-small",
|
||||
return OpenAILikeEmbedding(
|
||||
model_name=config.llm_embedding_model or "text-embedding-3-small",
|
||||
api_key=config.llm_api_key,
|
||||
api_base=endpoint,
|
||||
)
|
||||
@@ -54,7 +54,7 @@ def get_embedding_dim() -> int:
|
||||
config = AIConfig()
|
||||
model = config.llm_embedding_model or (
|
||||
"text-embedding-3-small"
|
||||
if config.llm_embedding_backend == "openai"
|
||||
if config.llm_embedding_backend == LLMEmbeddingBackend.OPENAI_LIKE
|
||||
else "sentence-transformers/all-MiniLM-L6-v2"
|
||||
)
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ def test_update_llm_index_removes_meta(
|
||||
config = AIConfig()
|
||||
expected_model = config.llm_embedding_model or (
|
||||
"text-embedding-3-small"
|
||||
if config.llm_embedding_backend == "openai"
|
||||
if config.llm_embedding_backend == "openai-like"
|
||||
else "sentence-transformers/all-MiniLM-L6-v2"
|
||||
)
|
||||
assert meta == {"embedding_model": expected_model, "dim": 384}
|
||||
|
||||
@@ -25,8 +25,8 @@ def mock_ollama_llm():
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_llm():
|
||||
with patch("llama_index.llms.openai.OpenAI") as MockOpenAI:
|
||||
yield MockOpenAI
|
||||
with patch("llama_index.llms.openai_like.OpenAILike") as MockOpenAILike:
|
||||
yield MockOpenAILike
|
||||
|
||||
|
||||
def test_get_llm_ollama(mock_ai_config, mock_ollama_llm):
|
||||
@@ -45,7 +45,7 @@ def test_get_llm_ollama(mock_ai_config, mock_ollama_llm):
|
||||
|
||||
|
||||
def test_get_llm_openai(mock_ai_config, mock_openai_llm):
|
||||
mock_ai_config.llm_backend = "openai"
|
||||
mock_ai_config.llm_backend = "openai-like"
|
||||
mock_ai_config.llm_model = "test_model"
|
||||
mock_ai_config.llm_api_key = "test_api_key"
|
||||
mock_ai_config.llm_endpoint = "http://test-url"
|
||||
@@ -56,12 +56,14 @@ def test_get_llm_openai(mock_ai_config, mock_openai_llm):
|
||||
model="test_model",
|
||||
api_base="http://test-url",
|
||||
api_key="test_api_key",
|
||||
is_chat_model=True,
|
||||
is_function_calling_model=True,
|
||||
)
|
||||
assert client.llm == mock_openai_llm.return_value
|
||||
|
||||
|
||||
def test_get_llm_openai_blocks_internal_endpoint_when_disallowed(mock_ai_config):
|
||||
mock_ai_config.llm_backend = "openai"
|
||||
mock_ai_config.llm_backend = "openai-like"
|
||||
mock_ai_config.llm_model = "test_model"
|
||||
mock_ai_config.llm_api_key = "test_api_key"
|
||||
mock_ai_config.llm_endpoint = "http://127.0.0.1:1234"
|
||||
|
||||
@@ -54,15 +54,17 @@ def mock_document():
|
||||
|
||||
|
||||
def test_get_embedding_model_openai(mock_ai_config):
|
||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI
|
||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
||||
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
||||
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
||||
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
||||
|
||||
with patch("llama_index.embeddings.openai.OpenAIEmbedding") as MockOpenAIEmbedding:
|
||||
with patch(
|
||||
"llama_index.embeddings.openai_like.OpenAILikeEmbedding",
|
||||
) as MockOpenAIEmbedding:
|
||||
model = get_embedding_model()
|
||||
MockOpenAIEmbedding.assert_called_once_with(
|
||||
model="text-embedding-3-small",
|
||||
model_name="text-embedding-3-small",
|
||||
api_key="test_api_key",
|
||||
api_base="http://test-url",
|
||||
)
|
||||
@@ -72,7 +74,7 @@ def test_get_embedding_model_openai(mock_ai_config):
|
||||
def test_get_embedding_model_openai_blocks_internal_endpoint_when_disallowed(
|
||||
mock_ai_config,
|
||||
):
|
||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI
|
||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
||||
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
||||
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
||||
mock_ai_config.return_value.llm_endpoint = "http://127.0.0.1:11434"
|
||||
@@ -109,7 +111,7 @@ def test_get_embedding_model_invalid_backend(mock_ai_config):
|
||||
|
||||
|
||||
def test_get_embedding_dim_infers_and_saves(temp_llm_index_dir, mock_ai_config):
|
||||
mock_ai_config.return_value.llm_embedding_backend = "openai"
|
||||
mock_ai_config.return_value.llm_embedding_backend = "openai-like"
|
||||
mock_ai_config.return_value.llm_embedding_model = None
|
||||
|
||||
class DummyEmbedding:
|
||||
@@ -129,7 +131,7 @@ def test_get_embedding_dim_infers_and_saves(temp_llm_index_dir, mock_ai_config):
|
||||
|
||||
|
||||
def test_get_embedding_dim_reads_existing_meta(temp_llm_index_dir, mock_ai_config):
|
||||
mock_ai_config.return_value.llm_embedding_backend = "openai"
|
||||
mock_ai_config.return_value.llm_embedding_backend = "openai-like"
|
||||
mock_ai_config.return_value.llm_embedding_model = None
|
||||
|
||||
(temp_llm_index_dir / "meta.json").write_text(
|
||||
@@ -142,7 +144,7 @@ def test_get_embedding_dim_reads_existing_meta(temp_llm_index_dir, mock_ai_confi
|
||||
|
||||
|
||||
def test_get_embedding_dim_raises_on_model_change(temp_llm_index_dir, mock_ai_config):
|
||||
mock_ai_config.return_value.llm_embedding_backend = "openai"
|
||||
mock_ai_config.return_value.llm_embedding_backend = "openai-like"
|
||||
mock_ai_config.return_value.llm_embedding_model = None
|
||||
|
||||
(temp_llm_index_dir / "meta.json").write_text(
|
||||
|
||||
Reference in New Issue
Block a user