From e34eda07bbc12f17b2b6ba46ea59aa63e73d3f25 Mon Sep 17 00:00:00 2001 From: Furkan URAL Date: Wed, 23 Sep 2026 21:44:37 +0300 Subject: [PATCH] Enhancement: support separate embedding API key (#14067) Co-authored-by: Claude Code Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/advanced_usage.md | 7 ++- docs/configuration.md | 7 +++ src-ui/src/app/data/paperless-config.ts | 9 ++++ src/documents/tests/test_api_app_config.py | 44 +++++++++++++++++++ src/paperless/config.py | 4 ++ ...tionconfiguration_llm_embedding_api_key.py | 23 ++++++++++ src/paperless/models.py | 7 +++ src/paperless/serialisers.py | 11 ++++- src/paperless/settings/__init__.py | 1 + src/paperless/tests/test_config_precedence.py | 24 ++++++++++ src/paperless_ai/embedding.py | 4 +- src/paperless_ai/tests/test_embedding.py | 19 +++++++- 12 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 src/paperless/migrations/0017_applicationconfiguration_llm_embedding_api_key.py diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index 661c2d704..105add465 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -153,8 +153,11 @@ in similar existing documents, and the document chat can retrieve relevant conte Enable it by setting [`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND) -(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only -built when AI is enabled **and** an embedding backend is set. +(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main +LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) +and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured. + +The index is only built when AI is enabled **and** an embedding backend is set. The index is updated automatically on a schedule controlled by [`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by diff --git a/docs/configuration.md b/docs/configuration.md index f48291824..2f94c41aa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2133,6 +2133,13 @@ for language and resource considerations. Defaults to None. +#### [`PAPERLESS_AI_LLM_EMBEDDING_API_KEY=`](#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) {#PAPERLESS_AI_LLM_EMBEDDING_API_KEY} + +: The API key to use for the embedding backend. If not supplied, embeddings use +`PAPERLESS_AI_LLM_API_KEY`. + + Defaults to None. + #### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT} : The endpoint / url to use for the embedding backend. If not supplied, embeddings use diff --git a/src-ui/src/app/data/paperless-config.ts b/src-ui/src/app/data/paperless-config.ts index 690d7aa4e..2568358bd 100644 --- a/src-ui/src/app/data/paperless-config.ts +++ b/src-ui/src/app/data/paperless-config.ts @@ -353,6 +353,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [ config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL', category: ConfigCategory.AI, }, + { + key: 'llm_embedding_api_key', + title: $localize`LLM Embedding API Key`, + type: ConfigOptionType.Password, + note: $localize`Used for embeddings when set, otherwise LLM API key is used.`, + config_key: 'PAPERLESS_AI_LLM_EMBEDDING_API_KEY', + category: ConfigCategory.AI, + }, { key: 'llm_embedding_endpoint', title: $localize`LLM Embedding Endpoint`, @@ -457,6 +465,7 @@ export interface PaperlessConfig extends ObjectWithId { ai_enabled: boolean llm_embedding_backend: string llm_embedding_model: string + llm_embedding_api_key: string llm_embedding_endpoint: string llm_embedding_chunk_size: number llm_context_size: number diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index ab05135aa..4566d668d 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -81,6 +81,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): "ai_enabled": None, "llm_embedding_backend": None, "llm_embedding_model": None, + "llm_embedding_api_key": None, "llm_embedding_endpoint": None, "llm_embedding_chunk_size": None, "llm_context_size": None, @@ -922,6 +923,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) self.assertEqual(ApplicationConfiguration.objects.count(), 1) + def test_update_llm_embedding_api_key(self) -> None: + """ + GIVEN: + - Existing config with llm_embedding_api_key specified + WHEN: + - API to update llm_embedding_api_key is called with all *s + - API to update llm_embedding_api_key is called with empty string + THEN: + - llm_embedding_api_key is unchanged + - llm_embedding_api_key is set to None + """ + config = ApplicationConfiguration.objects.first() + assert config is not None + config.llm_embedding_api_key = "1234567890" + config.save() + + # Test with all * + response = self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps( + { + "llm_embedding_api_key": "*" * 32, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + config.refresh_from_db() + self.assertEqual(config.llm_embedding_api_key, "1234567890") + # Test with empty string + response = self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps( + { + "llm_embedding_api_key": "", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + config.refresh_from_db() + self.assertEqual(config.llm_embedding_api_key, None) + def test_update_llm_api_key(self) -> None: """ GIVEN: diff --git a/src/paperless/config.py b/src/paperless/config.py index 5506d1fbd..116b18dde 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -245,6 +245,7 @@ class AIConfig(BaseConfig): ai_enabled: bool = dataclasses.field(init=False) llm_embedding_backend: str = dataclasses.field(init=False) llm_embedding_model: str = dataclasses.field(init=False) + llm_embedding_api_key: str = dataclasses.field(init=False) llm_embedding_endpoint: str = dataclasses.field(init=False) llm_embedding_chunk_size: int = dataclasses.field(init=False) llm_context_size: int = dataclasses.field(init=False) @@ -271,6 +272,9 @@ class AIConfig(BaseConfig): self.llm_embedding_model = ( app_config.llm_embedding_model or settings.LLM_EMBEDDING_MODEL ) + self.llm_embedding_api_key = ( + app_config.llm_embedding_api_key or settings.LLM_EMBEDDING_API_KEY + ) self.llm_embedding_endpoint = ( app_config.llm_embedding_endpoint or settings.LLM_EMBEDDING_ENDPOINT ) diff --git a/src/paperless/migrations/0017_applicationconfiguration_llm_embedding_api_key.py b/src/paperless/migrations/0017_applicationconfiguration_llm_embedding_api_key.py new file mode 100644 index 000000000..28259e447 --- /dev/null +++ b/src/paperless/migrations/0017_applicationconfiguration_llm_embedding_api_key.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.16 on 2026-09-11 09:32 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("paperless", "0016_alter_applicationconfiguration_ai_enabled"), + ] + + operations = [ + migrations.AddField( + model_name="applicationconfiguration", + name="llm_embedding_api_key", + field=models.CharField( + blank=True, + max_length=1024, + null=True, + verbose_name="Sets the LLM embedding API key", + ), + ), + ] diff --git a/src/paperless/models.py b/src/paperless/models.py index 64fc45fc0..480b8629f 100644 --- a/src/paperless/models.py +++ b/src/paperless/models.py @@ -365,6 +365,13 @@ class ApplicationConfiguration(AbstractSingletonModel): max_length=128, ) + llm_embedding_api_key = models.CharField( + verbose_name=_("Sets the LLM embedding API key"), + blank=True, + null=True, + max_length=1024, + ) + llm_embedding_endpoint = models.CharField( verbose_name=_("Sets the LLM embedding endpoint, optional"), blank=True, diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 7b32230f8..6430df889 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -216,6 +216,11 @@ class ApplicationConfigurationSerializer( externally_configured_variables = serializers.SerializerMethodField() user_args = serializers.JSONField(binary=True, allow_null=True) barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True) + llm_embedding_api_key = ObfuscatedPasswordField( + required=False, + allow_null=True, + max_length=1024, + ) llm_api_key = ObfuscatedPasswordField( required=False, allow_null=True, @@ -227,7 +232,11 @@ class ApplicationConfigurationSerializer( max_length=1024, ) - OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key") + OBFUSCATED_FIELDS = ( + "llm_embedding_api_key", + "llm_api_key", + "remote_ocr_api_key", + ) def get_externally_configured_variables( self, diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index a9a57a6b3..0de6d32e4 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -1236,6 +1236,7 @@ LLM_EMBEDDING_BACKEND = get_choice_from_env( {"huggingface", "openai-like", "ollama"}, ) LLM_EMBEDDING_MODEL = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_MODEL") +LLM_EMBEDDING_API_KEY = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_API_KEY") LLM_EMBEDDING_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT") LLM_EMBEDDING_CHUNK_SIZE = get_int_from_env( "PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE", diff --git a/src/paperless/tests/test_config_precedence.py b/src/paperless/tests/test_config_precedence.py index 2cc9aecf3..0b439dd1f 100644 --- a/src/paperless/tests/test_config_precedence.py +++ b/src/paperless/tests/test_config_precedence.py @@ -30,3 +30,27 @@ class TestBooleanConfigPrecedence(TestCase): config.save() self.assertTrue(AIConfig().ai_enabled) + + +class TestAIConfigPrecedence(TestCase): + @override_settings(LLM_EMBEDDING_API_KEY="environment-embedding-key") + def test_database_embedding_api_key_overrides_environment_setting(self) -> None: + config, _ = ApplicationConfiguration.objects.get_or_create() + config.llm_embedding_api_key = "database-embedding-key" + config.save() + + self.assertEqual( + AIConfig().llm_embedding_api_key, + "database-embedding-key", + ) + + @override_settings(LLM_EMBEDDING_API_KEY="environment-embedding-key") + def test_null_embedding_api_key_uses_environment_setting(self) -> None: + config, _ = ApplicationConfiguration.objects.get_or_create() + config.llm_embedding_api_key = None + config.save() + + self.assertEqual( + AIConfig().llm_embedding_api_key, + "environment-embedding-key", + ) diff --git a/src/paperless_ai/embedding.py b/src/paperless_ai/embedding.py index dbbb2b3be..0fd71423f 100644 --- a/src/paperless_ai/embedding.py +++ b/src/paperless_ai/embedding.py @@ -41,7 +41,9 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding": ) return OpenAILikeEmbedding( model_name=config.llm_embedding_model or "text-embedding-3-small", - api_key=config.llm_api_key or PLACEHOLDER_API_KEY, + api_key=config.llm_embedding_api_key + or config.llm_api_key + or PLACEHOLDER_API_KEY, api_base=endpoint, timeout=config.llm_request_timeout, http_client=http_client, diff --git a/src/paperless_ai/tests/test_embedding.py b/src/paperless_ai/tests/test_embedding.py index 1373a37bd..126a1a9f3 100644 --- a/src/paperless_ai/tests/test_embedding.py +++ b/src/paperless_ai/tests/test_embedding.py @@ -17,6 +17,7 @@ from paperless_ai.embedding import get_embedding_model @pytest.fixture def mock_ai_config(): with patch("paperless_ai.embedding.AIConfig") as MockAIConfig: + MockAIConfig.return_value.llm_embedding_api_key = None MockAIConfig.return_value.llm_embedding_endpoint = None MockAIConfig.return_value.llm_allow_internal_endpoints = True MockAIConfig.return_value.llm_context_size = 8192 @@ -63,6 +64,7 @@ def mock_document(): def test_get_embedding_model_openai(mock_ai_config): 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_embedding_api_key = "test_embedding_api_key" mock_ai_config.return_value.llm_api_key = "test_api_key" mock_ai_config.return_value.llm_endpoint = "http://test-url" @@ -72,7 +74,7 @@ def test_get_embedding_model_openai(mock_ai_config): model = get_embedding_model(mock_ai_config.return_value) MockOpenAIEmbedding.assert_called_once_with( model_name="text-embedding-3-small", - api_key="test_api_key", + api_key="test_embedding_api_key", api_base="http://test-url", timeout=120, http_client=ANY, @@ -81,6 +83,20 @@ def test_get_embedding_model_openai(mock_ai_config): assert model == MockOpenAIEmbedding.return_value +def test_get_embedding_model_openai_falls_back_to_llm_api_key(mock_ai_config): + 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_like.OpenAILikeEmbedding", + ) as MockOpenAIEmbedding: + get_embedding_model(mock_ai_config.return_value) + + assert MockOpenAIEmbedding.call_args.kwargs["api_key"] == "test_api_key" + + @pytest.mark.parametrize("configured_key", [None, ""]) def test_get_embedding_model_openai_without_api_key_sends_placeholder( mock_ai_config, @@ -89,6 +105,7 @@ def test_get_embedding_model_openai_without_api_key_sends_placeholder( """Same required key handling as the LLM client, see #13831.""" 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_embedding_api_key = configured_key mock_ai_config.return_value.llm_api_key = configured_key mock_ai_config.return_value.llm_endpoint = "http://test-url"