[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:
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user