From 19cc7a6f9f28b91f8c2cd41c1cdb3eedbda520e5 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Wed, 5 Aug 2026 23:04:17 -0700 Subject: [PATCH] [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 --- docs/admin/settings/settings_ai_summary.rst | 18 ++++ searx/ai_summary.py | 13 +++ searx/plugins/ai_summary.py | 53 +++++++++- searx/settings.yml | 5 + tests/unit/test_plugin_ai_summary.py | 105 ++++++++++++++++++++ 5 files changed, 190 insertions(+), 4 deletions(-) diff --git a/docs/admin/settings/settings_ai_summary.rst b/docs/admin/settings/settings_ai_summary.rst index 4de9fef1b..df6028fd4 100644 --- a/docs/admin/settings/settings_ai_summary.rst +++ b/docs/admin/settings/settings_ai_summary.rst @@ -16,6 +16,24 @@ only act as instance wide defaults. base_url: "http://127.0.0.1:11434" model: "llama3.2:3b" +An LLM server that requires authentication -- e.g. vLLM or llama.cpp started +with ``--api-key``, or a server behind an authenticating reverse proxy -- is +configured with an ``api_key``: + +.. code:: yaml + + ai_summary: + base_url: "http://127.0.0.1:8000" + api_key: "sk-..." + model: "llama3.2:3b" + +The key is sent in an ``Authorization: Bearer`` header. There is no user +preference for it, and it is only sent to the ``base_url`` above: a user who +points the ``ai_summary_server`` preference at a server of their own gets no +``Authorization`` header. SearXNG has no indirection for secrets in +``settings.yml``, so the file holding the key should be readable by the +SearXNG process only. + .. attention:: A user configurable server URL allows any user of the instance to make the diff --git a/searx/ai_summary.py b/searx/ai_summary.py index 5d4a0b482..e5f1b861a 100644 --- a/searx/ai_summary.py +++ b/searx/ai_summary.py @@ -49,6 +49,19 @@ class SettingsAISummary(msgspec.Struct, kw_only=True, forbid_unknown_fields=True can set their own server URL in the preferences (``ai_summary_server``) unless that preference is locked.""" + api_key: str = "" + """Optional API key of the LLM server in + :py:obj:`SettingsAISummary.base_url`, sent in an ``Authorization: Bearer`` + header. Needed by servers that require authentication, e.g. vLLM or + llama.cpp started with ``--api-key``, or an LLM server behind an + authenticating reverse proxy. + + There is intentionally no user preference for the API key, and the key is + **only** sent to :py:obj:`SettingsAISummary.base_url`: a user who points + the ``ai_summary_server`` preference at a server of their own gets no + ``Authorization`` header, so the key can't be captured by a third party + (see :py:obj:`searx.plugins.ai_summary._server_api_key`).""" + model: str = "" """Name of the default model (e.g. ``llama3.2:3b``). If empty, the first entry of :py:obj:`SettingsAISummary.models` is used. Users can set their diff --git a/searx/plugins/ai_summary.py b/searx/plugins/ai_summary.py index b4ae02f18..7c2adb0f1 100644 --- a/searx/plugins/ai_summary.py +++ b/searx/plugins/ai_summary.py @@ -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 ` 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: diff --git a/searx/settings.yml b/searx/settings.yml index 5b1829d08..67b0960da 100644 --- a/searx/settings.yml +++ b/searx/settings.yml @@ -297,6 +297,11 @@ plugins: # # ai_summary_server preference. # base_url: "http://127.0.0.1:11434" # +# # API key of the server above, if it requires authentication (e.g. vLLM or +# # llama.cpp with --api-key). Sent as "Authorization: Bearer" and only to +# # base_url, never to a server configured by a user in the preferences. +# api_key: "" +# # # Default model; if empty, the first entry of models: is used. # model: "llama3.2:3b" # diff --git a/tests/unit/test_plugin_ai_summary.py b/tests/unit/test_plugin_ai_summary.py index 70ac82fc3..1aadb75a4 100644 --- a/tests/unit/test_plugin_ai_summary.py +++ b/tests/unit/test_plugin_ai_summary.py @@ -39,6 +39,47 @@ def sse_stream_mock(lines: list[dict], status_code: int = 200) -> Mock: return client +class AISummaryAPIKey(SearxTestCase): + """The API key is administrator configuration and must only be sent to the + administrator's server, never to a server a user configured.""" + + def setUp(self): + super().setUp() + self.cfg = searx.get_setting("ai_summary") + self.setattr4test(self.cfg, "base_url", BASE_URL) + self.setattr4test(self.cfg, "api_key", "sk-secret") + + def test_auth_header_set_for_api_key(self): + with searx.plugins.ai_summary._get_client(BASE_URL, self.cfg, "sk-secret") as client: + self.assertEqual(client.headers["Authorization"], "Bearer sk-secret") + + def test_no_auth_header_without_api_key(self): + with searx.plugins.ai_summary._get_client(BASE_URL, self.cfg) as client: + self.assertNotIn("Authorization", client.headers) + + def test_key_sent_to_admin_server(self): + for server in [BASE_URL, BASE_URL + "/", BASE_URL + "/v1", "http://127.0.0.1:11434/v1/"]: + self.assertEqual("sk-secret", searx.plugins.ai_summary._server_api_key(self.cfg, server), server) + + def test_key_not_sent_to_other_server(self): + for server in [ + "http://192.168.1.10:11434", # other host + "http://127.0.0.1:8080", # other port + "https://127.0.0.1:11434", # other scheme + "http://127.0.0.1:11434/other", # other path + # the userinfo of a URL must not be mistaken for the host the + # request is sent to + "http://127.0.0.1:11434@untrusted.example.org", + "not a url", + "", + ]: + self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, server), server) + + def test_no_key_configured(self): + self.setattr4test(self.cfg, "api_key", "") + self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, BASE_URL)) + + class PluginAISummaryInit(SearxTestCase): def test_active_without_base_url(self): @@ -67,6 +108,41 @@ class PluginAISummaryInit(SearxTestCase): self.assertEqual(1, len(storage)) self.assertEqual([MODEL, "other-model"], searx.ai_summary.MODELS) + def test_model_probe_sends_api_key(self): + cfg = searx.get_setting("ai_summary") + self.setattr4test(cfg, "base_url", BASE_URL) + self.setattr4test(cfg, "model", "") + self.setattr4test(cfg, "models", []) + self.setattr4test(cfg, "api_key", "sk-secret") + self.setattr4test(searx.ai_summary, "MODELS", []) + + calls: list[tuple[str, str]] = [] + + class _FakeClient: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def get(self, _path): + resp = Mock() + resp.json.return_value = {"data": [{"id": "probed-model"}]} + return resp + + def record(base_url, _cfg, api_key=""): + calls.append((base_url, api_key)) + return _FakeClient() + + self.setattr4test(searx.plugins.ai_summary, "_get_client", record) + + storage = searx.plugins.PluginStorage() + storage.load_settings({PLUGIN_FQN: {"active": True}}) + storage.init(self.app) + + self.assertEqual([(BASE_URL, "sk-secret")], calls) + self.assertEqual(["probed-model"], searx.ai_summary.MODELS) + class PluginAISummary(SearxTestCase): @@ -95,6 +171,18 @@ class PluginAISummary(SearxTestCase): def mock_upstream(self, client_mock: Mock): self.setattr4test(searx.plugins.ai_summary, "_get_client", lambda *_args, **_kwargs: client_mock) + def mock_upstream_recording(self, client_mock: Mock) -> list[tuple[str, str]]: + """Like :py:obj:`mock_upstream`, the returned list records the + ``(base_url, api_key)`` the endpoint requested a client for.""" + calls: list[tuple[str, str]] = [] + + def record(base_url, _cfg, api_key=""): + calls.append((base_url, api_key)) + return client_mock + + self.setattr4test(searx.plugins.ai_summary, "_get_client", record) + return calls + def do_post_search(self, query, **kwargs) -> Mock: kwargs.setdefault("categories", ["general"]) search = get_search_mock(query, user_plugins=["ai_summary"], **kwargs) @@ -244,6 +332,23 @@ class PluginAISummary(SearxTestCase): lines = [json.loads(line) for line in res.data.decode().splitlines() if line] self.assertEqual(lines[-1], {"done": True, "model": "my-own-model:7b"}) + def test_endpoint_sends_api_key_to_admin_server(self): + self.setattr4test(searx.get_setting("ai_summary"), "api_key", "sk-secret") + calls = self.mock_upstream_recording(sse_stream_mock([])) + + res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]}) + self.assertEqual(res.status_code, 200) + self.assertEqual([(BASE_URL, "sk-secret")], calls) + + def test_endpoint_hides_api_key_from_user_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://untrusted.example.org:11434") + + res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]}) + self.assertEqual(res.status_code, 200) + self.assertEqual([("http://untrusted.example.org:11434", "")], calls) + def test_endpoint_upstream_error(self): self.mock_upstream(sse_stream_mock([], status_code=500))