[mod] plugin: AI tab only when activated, user API key, grounding on

Three changes to the ai_summary plugin:

- The *AI Summary* preferences tab is only rendered when the plugin is
  activated in settings.yml.  An instance that does not offer AI
  summaries no longer shows an AI tab at all.  The gate is the
  administrator setting, not the user opt-out, because the per user
  on/off switch lives inside that tab -- hiding it on opt-out would
  leave no way to opt back in.

- Users can configure an API key for their own LLM server
  (ai_summary_api_key).  The administrator key is still only sent to
  base_url and the user key only to a server the user configured, so
  neither key can be captured through the other.  The setting is marked
  secret: credentials are excluded from the preferences URL, which users
  copy around to transfer or share their preferences.

- Grounding summaries on the search results is now the default; the
  extra cost of the longer prompt is moderate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jasonwitty
2026-08-07 12:45:50 -07:00
co-authored by Claude Opus 5
parent ce400f993c
commit 8edc368752
10 changed files with 148 additions and 37 deletions
+15 -6
View File
@@ -27,12 +27,16 @@ configured with an ``api_key``:
api_key: "sk-..." api_key: "sk-..."
model: "llama3.2:3b" model: "llama3.2:3b"
The key is sent in an ``Authorization: Bearer`` header. There is no user The key is sent in an ``Authorization: Bearer`` header and only to the
preference for it, and it is only sent to the ``base_url`` above: a user who ``base_url`` above. A user who points the ``ai_summary_server`` preference at
points the ``ai_summary_server`` preference at a server of their own gets no a server of their own never gets the administrator's key; for such a server
``Authorization`` header. SearXNG has no indirection for secrets in the user configures their own key in the ``ai_summary_api_key`` preference.
``settings.yml``, so the file holding the key should be readable by the SearXNG has no indirection for secrets in ``settings.yml``, so the file
SearXNG process only. holding the key should be readable by the SearXNG process only.
The *AI Summary* tab of the preferences is only shown when the plugin is
activated in ``settings.yml`` (``active: true``); an instance that does not
offer AI summaries does not show the tab at all.
.. attention:: .. attention::
@@ -47,9 +51,14 @@ SearXNG process only.
preferences: preferences:
lock: lock:
- ai_summary_server - ai_summary_server
- ai_summary_api_key
- ai_summary_model - ai_summary_model
- ai_summary_grounding - ai_summary_grounding
Locking ``ai_summary_server`` and ``ai_summary_api_key`` matters most: an
unlocked pair lets any user of the instance make SearXNG send an
``Authorization`` header of their choosing to a host of their choosing.
.. _SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery .. _SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
.. autoclass:: searx.ai_summary.SettingsAISummary .. autoclass:: searx.ai_summary.SettingsAISummary
+11 -8
View File
@@ -56,11 +56,12 @@ class SettingsAISummary(msgspec.Struct, kw_only=True, forbid_unknown_fields=True
llama.cpp started with ``--api-key``, or an LLM server behind an llama.cpp started with ``--api-key``, or an LLM server behind an
authenticating reverse proxy. authenticating reverse proxy.
There is intentionally no user preference for the API key, and the key is This key is **only** sent to :py:obj:`SettingsAISummary.base_url`: a user
**only** sent to :py:obj:`SettingsAISummary.base_url`: a user who points who points the ``ai_summary_server`` preference at a server of their own
the ``ai_summary_server`` preference at a server of their own gets no gets no ``Authorization`` header from it, so the key can't be captured by
``Authorization`` header, so the key can't be captured by a third party a third party. For their own server, users configure their own key in the
(see :py:obj:`searx.plugins.ai_summary._server_api_key`).""" ``ai_summary_api_key`` preference (see
:py:obj:`searx.plugins.ai_summary._server_api_key`)."""
model: str = "" model: str = ""
"""Name of the default model (e.g. ``llama3.2:3b``). If empty, the first """Name of the default model (e.g. ``llama3.2:3b``). If empty, the first
@@ -73,10 +74,12 @@ class SettingsAISummary(msgspec.Struct, kw_only=True, forbid_unknown_fields=True
and :py:obj:`SettingsAISummary.base_url` is set, the list is requested and :py:obj:`SettingsAISummary.base_url` is set, the list is requested
once at application setup from the LLM server (``GET /v1/models``).""" once at application setup from the LLM server (``GET /v1/models``)."""
grounding: bool = False grounding: bool = True
"""Default of the ``ai_summary_grounding`` user preference: ground the """Default of the ``ai_summary_grounding`` user preference: ground the
summary on the search results. Users can still opt in/out in the summary on the search results. Grounded summaries are more accurate and
preferences unless that preference is locked.""" more current at a moderate extra cost (the search results are sent along
with the query, so the prompt is longer). Users can still opt in/out in
the preferences unless that preference is locked."""
connect_timeout: float = 5.0 connect_timeout: float = 5.0
"""Timeout (seconds) to establish a TCP connection to the LLM server.""" """Timeout (seconds) to establish a TCP connection to the LLM server."""
+20 -16
View File
@@ -36,9 +36,10 @@ applied to reach an LLM server in the local network.
A server that requires authentication (e.g. vLLM or llama.cpp started with A server that requires authentication (e.g. vLLM or llama.cpp started with
``--api-key``, or an LLM server behind an authenticating reverse proxy) is ``--api-key``, or an LLM server behind an authenticating reverse proxy) is
configured with an ``api_key``. The key is administrator configuration only: configured with an ``api_key``. The administrator's key is only sent to the
it is never exposed in the preferences and it is only sent to the server in server in ``base_url``, never to a server a user configured; for their own
``base_url``, never to a server a user configured (:py:obj:`_server_api_key`). server users configure their own key in the ``ai_summary_api_key`` preference
(:py:obj:`_server_api_key`).
Configuration of the defaults (:py:obj:`searx.ai_summary.SettingsAISummary`): Configuration of the defaults (:py:obj:`searx.ai_summary.SettingsAISummary`):
@@ -133,20 +134,22 @@ def _server_id(url: str) -> tuple[str, str, int, str] | None:
return (parsed.scheme, parsed.hostname.lower(), port, path) return (parsed.scheme, parsed.hostname.lower(), port, path)
def _server_api_key(cfg: SettingsAISummary, server: str) -> str: def _server_api_key(cfg: SettingsAISummary, server: str, user_api_key: str = "") -> str:
"""The API key to send to ``server``: the administrator's """The API key to send to ``server``:
:py:obj:`cfg.api_key <searx.ai_summary.SettingsAISummary.api_key>` if
``server`` *is* the administrator's server, an empty string otherwise.
Users can point the ``ai_summary_server`` preference at a server of their - the administrator's :py:obj:`cfg.api_key
own; without this check such a server would be sent the instance's API <searx.ai_summary.SettingsAISummary.api_key>` if ``server`` *is* the
key, which would hand every user of the instance a way to capture it.""" administrator's server (:py:obj:`cfg.base_url
if not cfg.api_key: <searx.ai_summary.SettingsAISummary.base_url>`),
return "" - otherwise the user's own ``ai_summary_api_key`` preference, which belongs
to the server in the user's own ``ai_summary_server`` preference.
The administrator's key is never sent to a server a user configured --
that would hand every user of the instance a way to capture it."""
server_id = _server_id(server) server_id = _server_id(server)
if server_id is None or server_id != _server_id(cfg.base_url): if server_id is not None and server_id == _server_id(cfg.base_url):
return "" return cfg.api_key
return cfg.api_key return user_api_key
def _user_server(request: "SXNG_Request", cfg: SettingsAISummary) -> str: def _user_server(request: "SXNG_Request", cfg: SettingsAISummary) -> str:
@@ -308,7 +311,8 @@ def ai_summary_view() -> flask.Response:
# open the upstream connection before streaming, a connection error is # open the upstream connection before streaming, a connection error is
# reported as HTTP 502 instead of a line in an already started stream # reported as HTTP 502 instead of a line in an already started stream
client = _get_client(server, cfg, _server_api_key(cfg, server)) user_api_key = str(sxng_request.preferences.get_value("ai_summary_api_key") or "").strip()
client = _get_client(server, cfg, _server_api_key(cfg, server, user_api_key))
stream_ctx = client.stream("POST", "/chat/completions", json=chat_payload) stream_ctx = client.stream("POST", "/chat/completions", json=chat_payload)
upstream = None upstream = None
try: try:
+13 -2
View File
@@ -49,10 +49,14 @@ class ValidationException(Exception):
class Setting: class Setting:
"""Base class of user settings""" """Base class of user settings"""
def __init__(self, default_value: t.Any, locked: bool = False): def __init__(self, default_value: t.Any, locked: bool = False, secret: bool = False):
super().__init__() super().__init__()
self.value: t.Any = default_value self.value: t.Any = default_value
self.locked: bool = locked self.locked: bool = locked
self.secret: bool = secret
"""The value is a credential: it is not included in the preferences URL
(:py:obj:`Preferences.get_as_url_params`), which users copy around to
transfer or share their preferences."""
def parse(self, data: str): def parse(self, data: str):
"""Parse ``data`` and store the result at ``self.value`` """Parse ``data`` and store the result at ``self.value``
@@ -468,6 +472,13 @@ class Preferences:
"", "",
locked="ai_summary_server" in self.cfg.lock, locked="ai_summary_server" in self.cfg.lock,
), ),
'ai_summary_api_key': StringSetting(
"",
locked="ai_summary_api_key" in self.cfg.lock,
# a user's API key is only sent to a server the user configured
# themselves, and it is never part of the preferences URL
secret=True,
),
'ai_summary_model': StringSetting( 'ai_summary_model': StringSetting(
"", "",
locked="ai_summary_model" in self.cfg.lock, locked="ai_summary_model" in self.cfg.lock,
@@ -512,7 +523,7 @@ class Preferences:
"""Return preferences as URL parameters""" """Return preferences as URL parameters"""
settings_kv = {} settings_kv = {}
for k, v in self.key_value_settings.items(): for k, v in self.key_value_settings.items():
if v.locked: if v.locked or v.secret:
continue continue
if isinstance(v, MultipleChoiceSetting): if isinstance(v, MultipleChoiceSetting):
settings_kv[k] = ','.join(v.get_value()) settings_kv[k] = ','.join(v.get_value())
+1 -1
View File
@@ -313,7 +313,7 @@ plugins:
# #
# # Ground summaries on the search results by default (users can still opt # # Ground summaries on the search results by default (users can still opt
# # in/out in their preferences). # # in/out in their preferences).
# grounding: false # grounding: true
categories_as_tabs: categories_as_tabs:
+4 -1
View File
@@ -251,7 +251,10 @@
{# tab: ai #} {# tab: ai #}
{%- if plugins_storage | selectattr('preference_section', 'equalto', 'ai') | list -%} {#- the tab is only shown when the administrator activated the plugin in
settings.yml, an instance without an AI summary has no AI tab -#}
{%- if 'ai_summary' in plugins_active_by_default
and plugins_storage | selectattr('preference_section', 'equalto', 'ai') | list -%}
{{- tab_header('maintab', 'ai', _('AI Summary')) -}} {{- tab_header('maintab', 'ai', _('AI Summary')) -}}
{{- plugin_preferences('ai') -}} {{- plugin_preferences('ai') -}}
{%- include 'simple/preferences/ai_summary.html' -%} {%- include 'simple/preferences/ai_summary.html' -%}
@@ -16,6 +16,19 @@
</div>{{- '' -}} </div>{{- '' -}}
</fieldset>{{- '' -}} </fieldset>{{- '' -}}
{%- endif -%} {%- endif -%}
{%- if 'ai_summary_api_key' not in locked_preferences -%}
<fieldset>{{- '' -}}
<legend id="pref_ai_summary_api_key">{{- _('AI server API key') -}}</legend>{{- '' -}}
<div class="value">{{- '' -}}
<input name="ai_summary_api_key" aria-labelledby="pref_ai_summary_api_key" type="password"
autocomplete="off" spellcheck="false" autocorrect="off"
value="{{ preferences.get_value('ai_summary_api_key') }}">{{- '' -}}
</div>{{- '' -}}
<div class="description">
{{- _('Only needed if the server URL above is your own server and it requires authentication. Leave empty for a server without authentication and for the default server of this instance (which uses the key of the administrator, never yours). The key is stored in a cookie in your browser and is not part of the preferences URL.') -}}
</div>{{- '' -}}
</fieldset>{{- '' -}}
{%- endif -%}
{%- if 'ai_summary_model' not in locked_preferences -%} {%- if 'ai_summary_model' not in locked_preferences -%}
<fieldset>{{- '' -}} <fieldset>{{- '' -}}
<legend id="pref_ai_summary_model">{{- _('AI summary model') -}}</legend>{{- '' -}} <legend id="pref_ai_summary_model">{{- _('AI summary model') -}}</legend>{{- '' -}}
+3
View File
@@ -980,6 +980,9 @@ def preferences():
shortcuts = {y: x for x, y in engine_shortcuts.items()}, shortcuts = {y: x for x, y in engine_shortcuts.items()},
themes = themes, themes = themes,
plugins_storage = searx.plugins.STORAGE.info, plugins_storage = searx.plugins.STORAGE.info,
# plugins the administrator activated in settings.yml; a plugin that is
# not activated does not get a preferences tab of its own
plugins_active_by_default = {plg.id for plg in searx.plugins.STORAGE if plg.active},
current_doi_resolver = get_doi_resolver(), current_doi_resolver = get_doi_resolver(),
allowed_plugins = allowed_plugins, allowed_plugins = allowed_plugins,
preferences_url_params = sxng_request.preferences.get_as_url_params(), preferences_url_params = sxng_request.preferences.get_as_url_params(),
+67 -2
View File
@@ -3,7 +3,9 @@
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
import json import json
from base64 import urlsafe_b64decode
from contextlib import contextmanager from contextlib import contextmanager
from zlib import decompress
from mock import Mock from mock import Mock
@@ -79,6 +81,19 @@ class AISummaryAPIKey(SearxTestCase):
self.setattr4test(self.cfg, "api_key", "") self.setattr4test(self.cfg, "api_key", "")
self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, BASE_URL)) self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, BASE_URL))
def test_user_key_goes_to_the_users_own_server(self):
key = searx.plugins.ai_summary._server_api_key(self.cfg, "http://192.168.1.10:11434", "sk-users-own")
self.assertEqual("sk-users-own", key)
def test_user_key_does_not_override_the_admin_key(self):
# the user's key belongs to the user's server; on the admin's server
# the admin's key is the right one
key = searx.plugins.ai_summary._server_api_key(self.cfg, BASE_URL, "sk-users-own")
self.assertEqual("sk-secret", key)
def test_no_key_for_a_user_server_without_a_user_key(self):
self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, "http://192.168.1.10:11434", ""))
class PluginAISummaryInit(SearxTestCase): class PluginAISummaryInit(SearxTestCase):
@@ -206,8 +221,10 @@ class PluginAISummary(SearxTestCase):
answer = list(search.result_container.answers)[0] answer = list(search.result_container.answers)[0]
self.assertTrue(answer.grounding) self.assertTrue(answer.grounding)
def test_grounding_default_from_settings(self): def test_grounding_is_on_by_default(self):
self.setattr4test(searx.get_setting("ai_summary"), "grounding", True) # note: AiSummary.__hash__ is hash(query), so two answers that differ
# only in .grounding compare equal -- assert on the attribute
self.assertTrue(searx.get_setting("ai_summary").grounding)
pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage) pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage)
with self.app.test_request_context(): with self.app.test_request_context():
@@ -216,6 +233,16 @@ class PluginAISummary(SearxTestCase):
answer = list(search.result_container.answers)[0] answer = list(search.result_container.answers)[0]
self.assertTrue(answer.grounding) self.assertTrue(answer.grounding)
def test_grounding_can_be_disabled_by_settings(self):
self.setattr4test(searx.get_setting("ai_summary"), "grounding", False)
pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage)
with self.app.test_request_context():
sxng_request.preferences = pref
search = self.do_post_search("lorem ipsum")
answer = list(search.result_container.answers)[0]
self.assertFalse(answer.grounding)
def test_skip_pageno(self): def test_skip_pageno(self):
with self.app.test_request_context(): with self.app.test_request_context():
sxng_request.preferences = self.pref sxng_request.preferences = self.pref
@@ -360,6 +387,44 @@ class PluginAISummary(SearxTestCase):
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual([(BASE_URL, "sk-secret")], calls) self.assertEqual([(BASE_URL, "sk-secret")], calls)
def test_endpoint_sends_the_users_key_to_the_users_server(self):
self.setattr4test(searx.get_setting("ai_summary"), "api_key", "sk-secret")
calls = self.mock_upstream_recording(sse_stream_mock([]))
self.client.set_cookie("ai_summary_server", "http://192.168.1.10:11434")
self.client.set_cookie("ai_summary_api_key", "sk-users-own")
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
self.assertEqual([("http://192.168.1.10:11434", "sk-users-own")], calls)
def test_api_key_is_not_part_of_the_preferences_url(self):
# users copy the preferences URL around to transfer/share their
# settings -- a credential must not travel with it
self.pref.parse_dict({"ai_summary_api_key": "sk-users-own"})
self.assertEqual("sk-users-own", self.pref.get_value("ai_summary_api_key"))
blob = self.pref.get_as_url_params()
decoded = decompress(urlsafe_b64decode(blob)).decode()
self.assertNotIn("sk-users-own", decoded)
self.assertNotIn("ai_summary_api_key", decoded)
# a non-secret preference of the same tab is still included
self.assertIn("ai_summary_model", decoded)
def test_preferences_tab_hidden_when_plugin_not_activated(self):
# the global STORAGE is what the preferences view renders from; in the
# default settings the ai_summary plugin is not activated
res = self.client.get("/preferences")
self.assertEqual(res.status_code, 200)
self.assertNotIn('tab-label-ai"', res.data.decode())
def test_preferences_tab_shown_when_plugin_activated(self):
self.setattr4test(searx.plugins, "STORAGE", self.storage)
res = self.client.get("/preferences")
self.assertEqual(res.status_code, 200)
html = res.data.decode()
self.assertIn('tab-label-ai"', html)
self.assertIn("ai_summary_api_key", html)
def test_endpoint_upstream_error(self): def test_endpoint_upstream_error(self):
self.mock_upstream(sse_stream_mock([], status_code=500)) self.mock_upstream(sse_stream_mock([], status_code=500))
+1 -1
View File
@@ -169,7 +169,7 @@ class TestPreferences(SearxTestCase):
self.preferences.parse_encoded_data(url_params) self.preferences.parse_encoded_data(url_params)
self.assertEqual( self.assertEqual(
vars(self.preferences.key_value_settings['categories']), vars(self.preferences.key_value_settings['categories']),
{'value': ['general'], 'locked': False, 'choices': ['general', 'none']}, {'value': ['general'], 'locked': False, 'secret': False, 'choices': ['general', 'none']},
) )
def test_save_key_value_setting(self): def test_save_key_value_setting(self):