19cc7a6f9f
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>
377 lines
16 KiB
Python
377 lines
16 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
# pylint: disable=missing-module-docstring,missing-class-docstring,invalid-name,protected-access
|
|
# pylint: disable=too-many-public-methods
|
|
|
|
import json
|
|
from contextlib import contextmanager
|
|
|
|
from mock import Mock
|
|
|
|
import searx.ai_summary
|
|
import searx.plugins
|
|
import searx.plugins.ai_summary
|
|
import searx.preferences
|
|
|
|
from searx.extended_types import sxng_request
|
|
from searx.result_types import AiSummary
|
|
|
|
from tests import SearxTestCase
|
|
from .test_plugins import get_search_mock
|
|
|
|
PLUGIN_FQN = "searx.plugins.ai_summary.SXNGPlugin"
|
|
BASE_URL = "http://127.0.0.1:11434"
|
|
MODEL = "test-model"
|
|
|
|
|
|
def sse_stream_mock(lines: list[dict], status_code: int = 200) -> Mock:
|
|
"""A mock httpx client whose ``stream()`` context manager yields the given
|
|
objects as a SSE stream (OpenAI chat completions format)."""
|
|
|
|
upstream = Mock(status_code=status_code)
|
|
upstream.iter_lines.return_value = iter([f"data: {json.dumps(line)}" for line in lines] + ["data: [DONE]"])
|
|
|
|
@contextmanager
|
|
def stream(*_args, **_kwargs):
|
|
yield upstream
|
|
|
|
client = Mock()
|
|
client.stream = stream
|
|
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):
|
|
# the Ollama server can be configured by the user in the preferences,
|
|
# the plugin stays active without an admin configured default
|
|
self.setattr4test(searx.ai_summary, "MODELS", [])
|
|
|
|
storage = searx.plugins.PluginStorage()
|
|
storage.load_settings({PLUGIN_FQN: {"active": True}})
|
|
storage.init(self.app)
|
|
|
|
self.assertEqual(1, len(storage))
|
|
self.assertEqual([], searx.ai_summary.MODELS)
|
|
|
|
def test_model_list_from_settings(self):
|
|
cfg = searx.get_setting("ai_summary")
|
|
self.setattr4test(cfg, "base_url", BASE_URL)
|
|
self.setattr4test(cfg, "model", MODEL)
|
|
self.setattr4test(cfg, "models", [MODEL, "other-model"])
|
|
self.setattr4test(searx.ai_summary, "MODELS", [])
|
|
|
|
storage = searx.plugins.PluginStorage()
|
|
storage.load_settings({PLUGIN_FQN: {"active": True}})
|
|
storage.init(self.app)
|
|
|
|
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):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
cfg = searx.get_setting("ai_summary")
|
|
self.setattr4test(cfg, "base_url", BASE_URL)
|
|
self.setattr4test(cfg, "model", MODEL)
|
|
self.setattr4test(cfg, "models", [MODEL])
|
|
self.setattr4test(searx.ai_summary, "MODELS", [])
|
|
|
|
self.storage = searx.plugins.PluginStorage()
|
|
self.storage.load_settings({PLUGIN_FQN: {"active": True}})
|
|
self.storage.init(self.app)
|
|
|
|
# the endpoint checks request.user_plugins (built in webapp.pre_request
|
|
# from the global plugin storage) -- enable the plugin like a browser
|
|
# with saved preferences does
|
|
self.client.set_cookie("disabled_plugins", "")
|
|
self.client.set_cookie("enabled_plugins", "ai_summary")
|
|
|
|
self.pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage)
|
|
self.pref.parse_dict({"locale": "en"})
|
|
|
|
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)
|
|
self.storage.post_search(sxng_request, search)
|
|
return search
|
|
|
|
def test_placeholder_answer_is_added(self):
|
|
with self.app.test_request_context():
|
|
sxng_request.preferences = self.pref
|
|
|
|
search = self.do_post_search("what is the best searx fork")
|
|
answer = AiSummary(query="what is the best searx fork", grounding=False)
|
|
self.assertIn(answer, search.result_container.answers)
|
|
|
|
def test_placeholder_carries_grounding_pref(self):
|
|
with self.app.test_request_context():
|
|
sxng_request.preferences = self.pref
|
|
self.pref.parse_dict({"ai_summary_grounding": "1"})
|
|
|
|
search = self.do_post_search("lorem ipsum")
|
|
answer = list(search.result_container.answers)[0]
|
|
self.assertTrue(answer.grounding)
|
|
|
|
def test_grounding_default_from_settings(self):
|
|
self.setattr4test(searx.get_setting("ai_summary"), "grounding", True)
|
|
pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage)
|
|
|
|
with self.app.test_request_context():
|
|
sxng_request.preferences = pref
|
|
search = self.do_post_search("lorem ipsum")
|
|
answer = list(search.result_container.answers)[0]
|
|
self.assertTrue(answer.grounding)
|
|
|
|
def test_skip_pageno(self):
|
|
with self.app.test_request_context():
|
|
sxng_request.preferences = self.pref
|
|
search = self.do_post_search("lorem ipsum", pageno=2)
|
|
self.assertEqual(list(search.result_container.answers), [])
|
|
|
|
def test_skip_non_html_format(self):
|
|
with self.app.test_request_context("/search", method="POST", data={"format": "json"}):
|
|
sxng_request.preferences = self.pref
|
|
search = self.do_post_search("lorem ipsum")
|
|
self.assertEqual(list(search.result_container.answers), [])
|
|
|
|
def test_skip_non_general_category(self):
|
|
with self.app.test_request_context():
|
|
sxng_request.preferences = self.pref
|
|
search = self.do_post_search("lorem ipsum", categories=["images"])
|
|
self.assertEqual(list(search.result_container.answers), [])
|
|
|
|
def test_skip_infobox(self):
|
|
with self.app.test_request_context():
|
|
sxng_request.preferences = self.pref
|
|
search = get_search_mock("lorem ipsum", user_plugins=["ai_summary"], categories=["general"])
|
|
search.result_container.infoboxes.append(Mock())
|
|
self.storage.post_search(sxng_request, search)
|
|
self.assertEqual(list(search.result_container.answers), [])
|
|
|
|
def test_skip_instant_answer(self):
|
|
# e.g. the "ddg definitions" engine adds wikipedia abstracts as Answer
|
|
from searx.result_types import Answer # pylint: disable=import-outside-toplevel
|
|
|
|
with self.app.test_request_context():
|
|
sxng_request.preferences = self.pref
|
|
search = get_search_mock("lorem ipsum", user_plugins=["ai_summary"], categories=["general"])
|
|
engine_answer = Answer(answer="Lorem ipsum is placeholder text. More at Wikipedia")
|
|
search.result_container.answers.add(engine_answer)
|
|
self.storage.post_search(sxng_request, search)
|
|
self.assertEqual(list(search.result_container.answers), [engine_answer])
|
|
|
|
def test_skip_without_any_server(self):
|
|
self.setattr4test(searx.get_setting("ai_summary"), "base_url", "")
|
|
with self.app.test_request_context():
|
|
sxng_request.preferences = self.pref
|
|
search = self.do_post_search("lorem ipsum")
|
|
self.assertEqual(list(search.result_container.answers), [])
|
|
|
|
def test_placeholder_with_user_server_only(self):
|
|
self.setattr4test(searx.get_setting("ai_summary"), "base_url", "")
|
|
with self.app.test_request_context():
|
|
sxng_request.preferences = self.pref
|
|
self.pref.parse_dict({"ai_summary_server": "http://192.168.1.10:11434"})
|
|
search = self.do_post_search("lorem ipsum")
|
|
self.assertEqual(len(list(search.result_container.answers)), 1)
|
|
|
|
def test_endpoint_forbidden_when_disabled(self):
|
|
self.client.set_cookie("disabled_plugins", "ai_summary")
|
|
self.client.set_cookie("enabled_plugins", "")
|
|
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
|
|
self.assertEqual(res.status_code, 403)
|
|
|
|
def test_endpoint_bad_request(self):
|
|
for body in [
|
|
None,
|
|
{},
|
|
{"messages": []},
|
|
{"messages": [{"role": "system", "content": "hi"}]},
|
|
{"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "ho"}]},
|
|
{"messages": [{"role": "user", "content": "x" * 5000}]},
|
|
{"messages": [{"role": "user", "content": "hi"}], "context": [{"unknown_key": "x"}]},
|
|
{"messages": [{"role": "user", "content": "hi"}] * 13},
|
|
]:
|
|
res = self.client.post("/ai_summary", json=body)
|
|
self.assertEqual(res.status_code, 400, body)
|
|
|
|
def test_endpoint_invalid_server_pref(self):
|
|
self.client.set_cookie("ai_summary_server", "ftp://example.org")
|
|
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
|
|
self.assertEqual(res.status_code, 400)
|
|
|
|
def test_endpoint_invalid_model_pref(self):
|
|
self.client.set_cookie("ai_summary_model", "bad model name!")
|
|
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
|
|
self.assertEqual(res.status_code, 400)
|
|
|
|
def test_endpoint_no_server_configured(self):
|
|
self.setattr4test(searx.get_setting("ai_summary"), "base_url", "")
|
|
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
|
|
self.assertEqual(res.status_code, 400)
|
|
|
|
def test_endpoint_streams_ndjson(self):
|
|
self.mock_upstream(
|
|
sse_stream_mock(
|
|
[
|
|
{"choices": [{"delta": {"content": "Hello "}}]},
|
|
{"choices": [{"delta": {"content": "world"}}]},
|
|
{"choices": [{"delta": {}, "finish_reason": "stop"}]},
|
|
]
|
|
)
|
|
)
|
|
|
|
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
|
|
self.assertEqual(res.status_code, 200)
|
|
self.assertEqual(res.headers["Content-Type"], "application/x-ndjson")
|
|
|
|
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
|
|
self.assertEqual(lines[0], {"delta": "Hello "})
|
|
self.assertEqual(lines[1], {"delta": "world"})
|
|
self.assertEqual(lines[2], {"done": True, "model": MODEL})
|
|
|
|
def test_endpoint_model_pref_wins(self):
|
|
self.mock_upstream(sse_stream_mock([]))
|
|
self.client.set_cookie("ai_summary_model", "my-own-model:7b")
|
|
|
|
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
|
|
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))
|
|
|
|
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
|
|
self.assertEqual(res.status_code, 502)
|
|
|
|
def test_endpoint_error_while_streaming(self):
|
|
upstream = Mock(status_code=200)
|
|
upstream.iter_lines.return_value = iter(
|
|
['data: {"choices": [{"delta": {"content": "Hello"}}]}', "data: this is not json"]
|
|
)
|
|
|
|
@contextmanager
|
|
def stream(*_args, **_kwargs):
|
|
yield upstream
|
|
|
|
client_mock = Mock()
|
|
client_mock.stream = stream
|
|
self.mock_upstream(client_mock)
|
|
|
|
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
|
|
self.assertEqual(res.status_code, 200)
|
|
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
|
|
self.assertEqual(lines[0], {"delta": "Hello"})
|
|
self.assertEqual(lines[1], {"done": True, "error": "upstream error"})
|