[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
parent ce400f993c
commit 8edc368752
10 changed files with 148 additions and 37 deletions
+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
``--api-key``, or an LLM server behind an authenticating reverse proxy) is
configured with an ``api_key``. The key is administrator configuration only:
it is never exposed in the preferences and it is only sent to the server in
``base_url``, never to a server a user configured (:py:obj:`_server_api_key`).
configured with an ``api_key``. The administrator's key is only sent to the
server in ``base_url``, never to a server a user configured; for their own
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`):
@@ -133,20 +134,22 @@ def _server_id(url: str) -> tuple[str, str, int, str] | None:
return (parsed.scheme, parsed.hostname.lower(), port, path)
def _server_api_key(cfg: SettingsAISummary, server: str) -> str:
"""The API key to send to ``server``: the administrator's
:py:obj:`cfg.api_key <searx.ai_summary.SettingsAISummary.api_key>` if
``server`` *is* the administrator's server, an empty string otherwise.
def _server_api_key(cfg: SettingsAISummary, server: str, user_api_key: str = "") -> str:
"""The API key to send to ``server``:
Users can point the ``ai_summary_server`` preference at a server of their
own; without this check such a server would be sent the instance's API
key, which would hand every user of the instance a way to capture it."""
if not cfg.api_key:
return ""
- the administrator's :py:obj:`cfg.api_key
<searx.ai_summary.SettingsAISummary.api_key>` if ``server`` *is* the
administrator's server (:py:obj:`cfg.base_url
<searx.ai_summary.SettingsAISummary.base_url>`),
- 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)
if server_id is None or server_id != _server_id(cfg.base_url):
return ""
return cfg.api_key
if server_id is not None and server_id == _server_id(cfg.base_url):
return cfg.api_key
return user_api_key
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
# 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)
upstream = None
try: