[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:
@@ -16,6 +16,24 @@ only act as instance wide defaults.
|
|||||||
base_url: "http://127.0.0.1:11434"
|
base_url: "http://127.0.0.1:11434"
|
||||||
model: "llama3.2:3b"
|
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::
|
.. attention::
|
||||||
|
|
||||||
A user configurable server URL allows any user of the instance to make the
|
A user configurable server URL allows any user of the instance to make the
|
||||||
|
|||||||
@@ -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``)
|
can set their own server URL in the preferences (``ai_summary_server``)
|
||||||
unless that preference is locked."""
|
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 = ""
|
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
|
||||||
entry of :py:obj:`SettingsAISummary.models` is used. Users can set their
|
entry of :py:obj:`SettingsAISummary.models` is used. Users can set their
|
||||||
|
|||||||
@@ -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
|
:py:obj:`searx.network`), an outgoing proxy configuration is deliberately not
|
||||||
applied to reach an LLM server in the local network.
|
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`):
|
Configuration of the defaults (:py:obj:`searx.ai_summary.SettingsAISummary`):
|
||||||
|
|
||||||
.. code:: yaml
|
.. code:: yaml
|
||||||
@@ -83,8 +89,10 @@ VALID_ROLES = ("user", "assistant")
|
|||||||
MODEL_NAME_REGEXP = re.compile(r"[A-Za-z0-9._:/-]{1,128}")
|
MODEL_NAME_REGEXP = re.compile(r"[A-Za-z0-9._:/-]{1,128}")
|
||||||
|
|
||||||
|
|
||||||
def _get_client(base_url: str, cfg: SettingsAISummary) -> httpx.Client:
|
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``."""
|
"""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
|
# the OpenAI API paths are prefixed with /v1, unless the base URL already
|
||||||
# points into an API prefix
|
# points into an API prefix
|
||||||
base_url = base_url.rstrip("/")
|
base_url = base_url.rstrip("/")
|
||||||
@@ -92,6 +100,7 @@ def _get_client(base_url: str, cfg: SettingsAISummary) -> httpx.Client:
|
|||||||
base_url += "/v1"
|
base_url += "/v1"
|
||||||
return httpx.Client(
|
return httpx.Client(
|
||||||
base_url=base_url,
|
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),
|
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
|
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:
|
def _user_server(request: "SXNG_Request", cfg: SettingsAISummary) -> str:
|
||||||
"""The LLM server URL for this request: the user's ``ai_summary_server``
|
"""The LLM server URL for this request: the user's ``ai_summary_server``
|
||||||
preference, or the administrator's default."""
|
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
|
/v1/models``). The server might not be up when SearXNG starts, a
|
||||||
failing probe only leaves the model suggestion list empty."""
|
failing probe only leaves the model suggestion list empty."""
|
||||||
try:
|
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 = client.get("/models")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
models = [model["id"] for model in resp.json().get("data", [])]
|
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
|
# 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)
|
client = _get_client(server, cfg, _server_api_key(cfg, server))
|
||||||
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:
|
||||||
|
|||||||
@@ -297,6 +297,11 @@ plugins:
|
|||||||
# # ai_summary_server preference.
|
# # ai_summary_server preference.
|
||||||
# base_url: "http://127.0.0.1:11434"
|
# 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.
|
# # Default model; if empty, the first entry of models: is used.
|
||||||
# model: "llama3.2:3b"
|
# model: "llama3.2:3b"
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -39,6 +39,47 @@ def sse_stream_mock(lines: list[dict], status_code: int = 200) -> Mock:
|
|||||||
return client
|
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):
|
class PluginAISummaryInit(SearxTestCase):
|
||||||
|
|
||||||
def test_active_without_base_url(self):
|
def test_active_without_base_url(self):
|
||||||
@@ -67,6 +108,41 @@ class PluginAISummaryInit(SearxTestCase):
|
|||||||
self.assertEqual(1, len(storage))
|
self.assertEqual(1, len(storage))
|
||||||
self.assertEqual([MODEL, "other-model"], searx.ai_summary.MODELS)
|
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):
|
class PluginAISummary(SearxTestCase):
|
||||||
|
|
||||||
@@ -95,6 +171,18 @@ class PluginAISummary(SearxTestCase):
|
|||||||
def mock_upstream(self, client_mock: Mock):
|
def mock_upstream(self, client_mock: Mock):
|
||||||
self.setattr4test(searx.plugins.ai_summary, "_get_client", lambda *_args, **_kwargs: client_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:
|
def do_post_search(self, query, **kwargs) -> Mock:
|
||||||
kwargs.setdefault("categories", ["general"])
|
kwargs.setdefault("categories", ["general"])
|
||||||
search = get_search_mock(query, user_plugins=["ai_summary"], **kwargs)
|
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]
|
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
|
||||||
self.assertEqual(lines[-1], {"done": True, "model": "my-own-model:7b"})
|
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):
|
def test_endpoint_upstream_error(self):
|
||||||
self.mock_upstream(sse_stream_mock([], status_code=500))
|
self.mock_upstream(sse_stream_mock([], status_code=500))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user