diff --git a/changedetectionio/blueprint/settings/__init__.py b/changedetectionio/blueprint/settings/__init__.py
index 19962aea8..f6b062187 100644
--- a/changedetectionio/blueprint/settings/__init__.py
+++ b/changedetectionio/blueprint/settings/__init__.py
@@ -238,6 +238,15 @@ def construct_blueprint(datastore: ChangeDetectionStore):
)
llm_config = _get_llm_cfg(datastore) or {}
llm_env_configured = llm_configured_via_env()
+
+ # Once a key is stored, the provider and the key itself are locked and can only be
+ # cleared with "Remove provider". The key field renders blank so that an untouched
+ # save preserves it, which makes any stray value in it - a browser autofill, a bad
+ # paste - silently overwrite a working key on the next Save, with no copy kept
+ # anywhere to recover from. Disabling the input takes it out of the POST entirely;
+ # the form is seeded with data=default, so an absent field falls back to the stored
+ # value and the merge below is a no-op for it.
+ llm_provider_locked = bool(llm_config.get('api_key')) and not llm_env_configured
llm_stored = datastore.data['settings']['application'].get('llm') or {}
llm_token_budget_month = get_global_token_budget_month(datastore)
llm_token_budget_month_env = get_global_token_budget_month() # env var only, for readonly logic
@@ -253,6 +262,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
api_key=datastore.data['settings']['application'].get('api_access_token'),
llm_config=llm_config,
llm_env_configured=llm_env_configured,
+ llm_provider_locked=llm_provider_locked,
llm_stored=llm_stored,
llm_token_budget_month=llm_token_budget_month,
llm_token_budget_month_env=llm_token_budget_month_env,
diff --git a/changedetectionio/blueprint/settings/templates/settings_llm_tab.html b/changedetectionio/blueprint/settings/templates/settings_llm_tab.html
index 36a76997d..02a90e54b 100644
--- a/changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+++ b/changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -118,7 +118,7 @@
{{ _('Provider') }}
-
+
— {{ _('select a provider') }} —
Anthropic
Google (Gemini)
@@ -130,8 +130,13 @@
- {{ render_field(form.llm.form.api_key) }}
-
+ {#- Disabled once a key is stored: a disabled input is not submitted at all, so there is
+ no way for an autofilled or mistyped value to reach the save path and overwrite a
+ working key. "Remove provider" is the only way to change it. -#}
+ {{ render_field(form.llm.form.api_key, disabled=llm_provider_locked) }}
+
+ {%- if llm_provider_locked %}{{ _('Saved. Use "Remove provider" below to change the provider or key.') }}{% endif -%}
+
{{ render_field(form.llm.form.api_base) }}
@@ -202,7 +207,7 @@
data-confirm-message="
{{ _('This will remove your saved AI provider, model, and API key.') }}
"
data-confirm-button="{{ _('Remove') }}"
data-cancel-button="{{ _('Cancel') }}">
- ✕ {{ _('Remove') }}
+ ✕ {{ _('Remove provider') }}
the changedetectionio package.
+APP_BASE = Path(__file__).resolve().parents[1]
+
def _configure_llm(datastore, api_key=CANARY_KEY):
"""Inject a recognisable API key into the datastore LLM settings."""
@@ -32,6 +38,16 @@ def _configure_llm(datastore, api_key=CANARY_KEY):
})
+def _element(page: bytes, element_id: bytes, tag: bytes = b' bytes:
+ """Return the whole opening tag carrying `element_id`.
+
+ Not page.split(id)[1] - WTForms emits attributes alphabetically, so `autocomplete` and
+ `disabled` land BEFORE `id=` and slicing forwards from the id silently misses them.
+ """
+ i = page.index(element_id)
+ return page[page.rindex(tag, 0, i):page.index(b'>', i) + 1]
+
+
def _api_token(client):
return client.application.config.get('DATASTORE').data['settings']['application'].get('api_access_token')
@@ -353,6 +369,126 @@ def test_settings_form_preserves_api_key_when_submitted_blank(
delete_all_watches(client)
+def test_settings_form_preserves_api_key_when_field_is_absent(
+ client, live_server, measure_memory_usage, datastore_path):
+ """
+ Once a key is stored the field renders disabled, and a disabled input is not submitted
+ at all - so the save path sees no llm-api_key key whatsoever, not an empty one.
+
+ That must preserve the stored key. It does, because the form is built with data=default
+ and the merge is {**stored, **form_input}, but it is exactly the kind of thing a later
+ refactor breaks silently: the symptom is a working API key replaced by nothing, with no
+ copy kept anywhere to restore it from.
+ """
+ ds = client.application.config.get('DATASTORE')
+ _configure_llm(ds, api_key='sk-should-survive-absence')
+
+ res = client.post(
+ url_for('settings.settings_page'),
+ data={
+ 'llm-model': 'gpt-4o',
+ # no 'llm-api_key' at all — this is what a disabled input submits
+ 'llm-api_base': '',
+ 'application-pager_size': '50',
+ 'application-notification_format': 'System default',
+ 'requests-time_between_check-days': '0',
+ 'requests-time_between_check-hours': '0',
+ 'requests-time_between_check-minutes': '5',
+ 'requests-time_between_check-seconds': '0',
+ 'requests-time_between_check-weeks': '0',
+ 'requests-workers': '10',
+ 'requests-timeout': '60',
+ },
+ follow_redirects=True,
+ )
+ assert res.status_code == 200
+
+ saved_key = ds.data['settings']['application'].get('llm', {}).get('api_key', '')
+ assert saved_key == 'sk-should-survive-absence', \
+ f"An absent llm-api_key must not clear the stored key (got '{saved_key}')"
+
+ delete_all_watches(client)
+
+
+def test_datastore_never_ends_up_with_a_blank_key(
+ client, live_server, measure_memory_usage, datastore_path):
+ """A stored key must never be replaced by an empty/whitespace value through the form.
+
+ Whatever the field submits - missing, empty, or whitespace - the stored key stands. The
+ only supported way to clear it is "Remove provider" (llm_clear).
+ """
+ ds = client.application.config.get('DATASTORE')
+
+ base = {
+ 'llm-model': 'gpt-4o',
+ 'llm-api_base': '',
+ 'application-pager_size': '50',
+ 'application-notification_format': 'System default',
+ 'requests-time_between_check-days': '0',
+ 'requests-time_between_check-hours': '0',
+ 'requests-time_between_check-minutes': '5',
+ 'requests-time_between_check-seconds': '0',
+ 'requests-time_between_check-weeks': '0',
+ 'requests-workers': '10',
+ 'requests-timeout': '60',
+ }
+
+ for label, extra in (('absent', {}), ('empty', {'llm-api_key': ''}),
+ ('whitespace', {'llm-api_key': ' '})):
+ _configure_llm(ds, api_key='sk-original')
+ client.post(url_for('settings.settings_page'), data={**base, **extra},
+ follow_redirects=True)
+ saved = ds.data['settings']['application'].get('llm', {}).get('api_key', '')
+ assert saved == 'sk-original', f"{label} submission blanked the stored key (got '{saved}')"
+
+ delete_all_watches(client)
+
+
+def test_provider_and_key_are_locked_once_a_key_is_stored(
+ client, live_server, measure_memory_usage, datastore_path):
+ """Locked means disabled, not readonly: a disabled input never reaches the POST, so no
+ autofilled or mistyped value can overwrite the key."""
+ ds = client.application.config.get('DATASTORE')
+ _configure_llm(ds, api_key='sk-locked')
+
+ page = client.get(url_for('settings.settings_page')).data
+ key_field = _element(page, b'id="llm-api_key"')
+ assert b'disabled' in key_field, "stored key must render the field disabled"
+
+ provider = _element(page, b'id="llm-provider"', tag=b'\n"
"Language-Team: LANGUAGE \n"
@@ -1052,6 +1052,10 @@ msgstr ""
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Saved. Use \"Remove provider\" below to change the provider or key."
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
@@ -1109,6 +1113,10 @@ msgstr ""
msgid "Cancel"
msgstr ""
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Remove provider"
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Test connection"
msgstr ""
diff --git a/changedetectionio/translations/pl/LC_MESSAGES/messages.po b/changedetectionio/translations/pl/LC_MESSAGES/messages.po
index f4de18a58..97ef36686 100644
--- a/changedetectionio/translations/pl/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/pl/LC_MESSAGES/messages.po
@@ -1145,6 +1145,10 @@ msgstr "wybierz dostawcę"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr "Zgodność z OpenAI (vLLM, LM Studio, llama.cpp)"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Saved. Use \"Remove provider\" below to change the provider or key."
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
@@ -1210,6 +1214,10 @@ msgstr "Usuń"
msgid "Cancel"
msgstr "Anuluj"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Remove provider"
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Test connection"
msgstr "Sprawdź połączenie"
diff --git a/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po b/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po
index 4cd018b1b..8d15fdcc0 100644
--- a/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po
@@ -1078,6 +1078,10 @@ msgstr ""
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Saved. Use \"Remove provider\" below to change the provider or key."
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
@@ -1135,6 +1139,10 @@ msgstr ""
msgid "Cancel"
msgstr "Cancelar"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Remove provider"
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Test connection"
msgstr ""
diff --git a/changedetectionio/translations/ru/LC_MESSAGES/messages.po b/changedetectionio/translations/ru/LC_MESSAGES/messages.po
index 4b80e4212..2b39f2994 100644
--- a/changedetectionio/translations/ru/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/ru/LC_MESSAGES/messages.po
@@ -1119,6 +1119,10 @@ msgstr "выберите провайдера"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr "Совместимость с OpenAI (vLLM, LM Studio, llama.cpp)"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Saved. Use \"Remove provider\" below to change the provider or key."
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
@@ -1184,6 +1188,10 @@ msgstr "Удалять"
msgid "Cancel"
msgstr "Отмена"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Remove provider"
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Test connection"
msgstr "Тестовое соединение"
diff --git a/changedetectionio/translations/tr/LC_MESSAGES/messages.po b/changedetectionio/translations/tr/LC_MESSAGES/messages.po
index 69f04c9c0..146a073fa 100644
--- a/changedetectionio/translations/tr/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/tr/LC_MESSAGES/messages.po
@@ -1088,6 +1088,10 @@ msgstr ""
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Saved. Use \"Remove provider\" below to change the provider or key."
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
@@ -1145,6 +1149,10 @@ msgstr ""
msgid "Cancel"
msgstr "İptal"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Remove provider"
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Test connection"
msgstr ""
diff --git a/changedetectionio/translations/uk/LC_MESSAGES/messages.po b/changedetectionio/translations/uk/LC_MESSAGES/messages.po
index 8ad762453..b65a8a29b 100644
--- a/changedetectionio/translations/uk/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/uk/LC_MESSAGES/messages.po
@@ -1068,6 +1068,10 @@ msgstr ""
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Saved. Use \"Remove provider\" below to change the provider or key."
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
@@ -1125,6 +1129,10 @@ msgstr ""
msgid "Cancel"
msgstr "Скасувати"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Remove provider"
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Test connection"
msgstr ""
diff --git a/changedetectionio/translations/zh/LC_MESSAGES/messages.po b/changedetectionio/translations/zh/LC_MESSAGES/messages.po
index dda8c54df..e16f3a798 100644
--- a/changedetectionio/translations/zh/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/zh/LC_MESSAGES/messages.po
@@ -1058,6 +1058,10 @@ msgstr ""
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Saved. Use \"Remove provider\" below to change the provider or key."
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
@@ -1115,6 +1119,10 @@ msgstr ""
msgid "Cancel"
msgstr "取消"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Remove provider"
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Test connection"
msgstr ""
diff --git a/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po b/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po
index 430cb7637..b5695041b 100644
--- a/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po
@@ -1059,6 +1059,10 @@ msgstr "選擇供應商"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr "與 OpenAI 相容(vLLM、LM Studio、llama.cpp)"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Saved. Use \"Remove provider\" below to change the provider or key."
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr "僅適用於 Ollama 或自訂 / 自行架設的端點。雲端供應商請留空。"
@@ -1119,6 +1123,10 @@ msgstr "移除"
msgid "Cancel"
msgstr "取消"
+#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
+msgid "Remove provider"
+msgstr ""
+
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Test connection"
msgstr "測試連線"