[feat] plugin: optional API key for the AI summary LLM server

Servers that require authentication (e.g. vLLM or llama.cpp started with
--api-key, or an LLM server behind an authenticating reverse proxy) can
now be configured with an ai_summary.api_key, sent as "Authorization:
Bearer".

The key is administrator configuration only: there is no preference for
it, and it is only sent to the configured base_url.  Users can point the
ai_summary_server preference at a server of their own, and such a server
must not be handed the instance API key -- otherwise every user of the
instance could capture it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jasonwitty
2026-08-05 23:04:17 -07:00
co-authored by Claude Opus 5
parent 4abb7dba67
commit 19cc7a6f9f
5 changed files with 190 additions and 4 deletions
+49 -4
View File
@@ -34,6 +34,12 @@ The requests to the LLM server are sent directly (not via
:py:obj:`searx.network`), an outgoing proxy configuration is deliberately not
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`).
Configuration of the defaults (:py:obj:`searx.ai_summary.SettingsAISummary`):
.. code:: yaml
@@ -83,8 +89,10 @@ VALID_ROLES = ("user", "assistant")
MODEL_NAME_REGEXP = re.compile(r"[A-Za-z0-9._:/-]{1,128}")
def _get_client(base_url: str, cfg: SettingsAISummary) -> httpx.Client:
"""HTTP client for one request to the LLM server at ``base_url``."""
def _get_client(base_url: str, cfg: SettingsAISummary, api_key: str = "") -> httpx.Client:
"""HTTP client for one request to the LLM server at ``base_url``. The
``api_key`` (if any) is sent in an ``Authorization: Bearer`` header, see
:py:obj:`_server_api_key`."""
# the OpenAI API paths are prefixed with /v1, unless the base URL already
# points into an API prefix
base_url = base_url.rstrip("/")
@@ -92,6 +100,7 @@ def _get_client(base_url: str, cfg: SettingsAISummary) -> httpx.Client:
base_url += "/v1"
return httpx.Client(
base_url=base_url,
headers={"Authorization": f"Bearer {api_key}"} if api_key else None,
timeout=httpx.Timeout(connect=cfg.connect_timeout, read=cfg.read_timeout, write=10.0, pool=10.0),
)
@@ -104,6 +113,42 @@ def _valid_server(url: str) -> bool:
return parsed.scheme in ("http", "https") and bool(parsed.netloc) and len(url) <= 256
def _server_id(url: str) -> tuple[str, str, int, str] | None:
"""Identity of an LLM server URL (scheme, host, port, path) for comparing
two URLs, or ``None`` if the URL is unusable. The ``/v1`` API prefix is
not part of the identity, :py:obj:`_get_client` appends it when missing."""
try:
parsed = urlparse(url)
except ValueError:
return None
# .hostname (not .netloc) drops the userinfo, so that a server URL like
# http://llm.example.org@untrusted.example.org/ is identified by the host
# the request is actually sent to (untrusted.example.org)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return None
port = parsed.port or (443 if parsed.scheme == "https" else 80)
path = parsed.path.rstrip("/")
if path.endswith("/v1"):
path = path[: -len("/v1")].rstrip("/")
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.
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 ""
server_id = _server_id(server)
if server_id is None or server_id != _server_id(cfg.base_url):
return ""
return cfg.api_key
def _user_server(request: "SXNG_Request", cfg: SettingsAISummary) -> str:
"""The LLM server URL for this request: the user's ``ai_summary_server``
preference, or the administrator's default."""
@@ -146,7 +191,7 @@ class SXNGPlugin(Plugin):
/v1/models``). The server might not be up when SearXNG starts, a
failing probe only leaves the model suggestion list empty."""
try:
with _get_client(cfg.base_url, cfg) as client:
with _get_client(cfg.base_url, cfg, cfg.api_key) as client:
resp = client.get("/models")
resp.raise_for_status()
models = [model["id"] for model in resp.json().get("data", [])]
@@ -255,7 +300,7 @@ 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)
client = _get_client(server, cfg, _server_api_key(cfg, server))
stream_ctx = client.stream("POST", "/chat/completions", json=chat_payload)
upstream = None
try: