[mod] ai_summary plugin: switch to the OpenAI chat completions API
Talk to the LLM server via GET /v1/models and POST /v1/chat/completions (SSE) instead of Ollama's native API. Any OpenAI compatible server now works (Ollama, vLLM, llama.cpp, LM Studio, Hugging Face TGI, ...); Ollama serves this API natively, existing setups keep working unchanged. The Ollama specific keep_alive option is dropped, the ai_summary.grounding setting is added as instance wide default of the grounding preference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+38
-27
@@ -1,8 +1,10 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Plugin that displays an AI generated summary of the search query at the top
|
||||
of the result page. The summary is generated by a (local) `Ollama`_ server.
|
||||
of the result page. The summary is generated by a (local) LLM server that
|
||||
implements the `OpenAI chat completions API`_ -- e.g. `Ollama`_, vLLM,
|
||||
llama.cpp, LM Studio or Hugging Face TGI.
|
||||
|
||||
The Ollama server URL and the model are configured by the user in the *AI
|
||||
The LLM server URL and the model are configured by the user in the *AI
|
||||
Summary* tab of the preferences (``ai_summary_server``, ``ai_summary_model``);
|
||||
the administrator can configure instance wide defaults in the ``ai_summary:``
|
||||
section and lock the preferences via :ref:`settings preferences`.
|
||||
@@ -20,17 +22,17 @@ The result page is never delayed by this plugin: it only places an empty
|
||||
placeholder (:py:obj:`searx.result_types.AiSummary`) in the answer area, which
|
||||
is filled asynchronously by the client (``client/simple/src/js/plugin/
|
||||
AiSummary.ts``) from the ``/ai_summary`` endpoint (registered in
|
||||
:py:obj:`searx.webapp`). The endpoint streams the tokens from Ollama's
|
||||
``/api/chat`` to the client as `NDJSON`_.
|
||||
:py:obj:`searx.webapp`). The endpoint re-emits the SSE token stream of the
|
||||
LLM server's ``/v1/chat/completions`` to the client as `NDJSON`_.
|
||||
|
||||
A summary is only generated on the first page of a *general* search and only
|
||||
if no engine has contributed an infobox (e.g. wikipedia / wikidata) or an
|
||||
instant answer (e.g. ddg definitions) -- in these cases the query is most
|
||||
likely a lookup of a well known term that is already answered.
|
||||
|
||||
The requests to the Ollama server are sent directly (not via
|
||||
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 Ollama server in the local network.
|
||||
applied to reach an LLM server in the local network.
|
||||
|
||||
Configuration of the defaults (:py:obj:`searx.ai_summary.SettingsAISummary`):
|
||||
|
||||
@@ -47,6 +49,7 @@ Configuration of the defaults (:py:obj:`searx.ai_summary.SettingsAISummary`):
|
||||
active: false
|
||||
|
||||
.. _Ollama: https://ollama.com/
|
||||
.. _OpenAI chat completions API: https://platform.openai.com/docs/api-reference/chat
|
||||
.. _NDJSON: https://github.com/ndjson/ndjson-spec
|
||||
.. _SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
|
||||
"""
|
||||
@@ -64,7 +67,7 @@ import httpx
|
||||
from flask_babel import gettext
|
||||
|
||||
from searx import get_setting
|
||||
from searx.ai_summary import SettingsAISummary, build_ollama_messages
|
||||
from searx.ai_summary import SettingsAISummary, build_chat_messages
|
||||
from searx.extended_types import sxng_request
|
||||
from searx.result_types import EngineResults
|
||||
import searx.ai_summary
|
||||
@@ -81,7 +84,12 @@ 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 Ollama server at ``base_url``."""
|
||||
"""HTTP client for one request to the LLM server at ``base_url``."""
|
||||
# the OpenAI API paths are prefixed with /v1, unless the base URL already
|
||||
# points into an API prefix
|
||||
base_url = base_url.rstrip("/")
|
||||
if not base_url.endswith("/v1"):
|
||||
base_url += "/v1"
|
||||
return httpx.Client(
|
||||
base_url=base_url,
|
||||
timeout=httpx.Timeout(connect=cfg.connect_timeout, read=cfg.read_timeout, write=10.0, pool=10.0),
|
||||
@@ -97,7 +105,7 @@ def _valid_server(url: str) -> bool:
|
||||
|
||||
|
||||
def _user_server(request: "SXNG_Request", cfg: SettingsAISummary) -> str:
|
||||
"""The Ollama 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."""
|
||||
return str(request.preferences.get_value("ai_summary_server") or "").strip() or cfg.base_url
|
||||
|
||||
@@ -116,7 +124,7 @@ class SXNGPlugin(Plugin):
|
||||
name=gettext("AI summary"),
|
||||
description=gettext(
|
||||
"Show an AI generated summary of the search query on top of the"
|
||||
" result page (uses an Ollama server, see the settings below)."
|
||||
" result page (uses a local LLM server, see the settings below)."
|
||||
),
|
||||
preference_section="ai",
|
||||
)
|
||||
@@ -134,14 +142,14 @@ class SXNGPlugin(Plugin):
|
||||
return True
|
||||
|
||||
def _probe_models(self, cfg: SettingsAISummary) -> list[str]:
|
||||
"""Request the list of models from the Ollama server (``GET
|
||||
/api/tags``). The server might not be up when SearXNG starts, a
|
||||
"""Request the list of models from the LLM server (``GET
|
||||
/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:
|
||||
resp = client.get("/api/tags")
|
||||
resp = client.get("/models")
|
||||
resp.raise_for_status()
|
||||
models = [model["name"] for model in resp.json().get("models", [])]
|
||||
models = [model["id"] for model in resp.json().get("data", [])]
|
||||
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||
self.log.warning("can't request model list from %s: %s", cfg.base_url, exc)
|
||||
models = []
|
||||
@@ -163,7 +171,7 @@ class SXNGPlugin(Plugin):
|
||||
or bool(search.result_container.infoboxes)
|
||||
or bool(search.result_container.answers)
|
||||
or not sq.query.strip()
|
||||
# without an Ollama server (user preference or admin default)
|
||||
# without an LLM server (user preference or admin default)
|
||||
# there is nothing to show
|
||||
or not _user_server(request, cfg)
|
||||
)
|
||||
@@ -233,23 +241,22 @@ def ai_summary_view() -> flask.Response:
|
||||
|
||||
server = _user_server(sxng_request, cfg)
|
||||
if not _valid_server(server):
|
||||
return _bad_request("no valid Ollama server configured")
|
||||
return _bad_request("no valid LLM server configured")
|
||||
|
||||
model = str(sxng_request.preferences.get_value("ai_summary_model") or "").strip() or cfg.model
|
||||
if not MODEL_NAME_REGEXP.fullmatch(model):
|
||||
return _bad_request("no valid model configured")
|
||||
|
||||
ollama_payload = {
|
||||
chat_payload = {
|
||||
"model": model,
|
||||
"messages": build_ollama_messages(cfg, messages, context),
|
||||
"messages": build_chat_messages(cfg, messages, context),
|
||||
"stream": True,
|
||||
"keep_alive": cfg.keep_alive,
|
||||
}
|
||||
|
||||
# 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)
|
||||
stream_ctx = client.stream("POST", "/api/chat", json=ollama_payload)
|
||||
stream_ctx = client.stream("POST", "/chat/completions", json=chat_payload)
|
||||
upstream = None
|
||||
try:
|
||||
upstream = stream_ctx.__enter__() # pylint: disable=unnecessary-dunder-call
|
||||
@@ -273,22 +280,26 @@ def ai_summary_view() -> flask.Response:
|
||||
def generate():
|
||||
start = time.monotonic()
|
||||
try:
|
||||
# the upstream is a SSE stream: "data: {..}" lines, terminated by
|
||||
# a "data: [DONE]" line
|
||||
for line in upstream.iter_lines():
|
||||
if time.monotonic() - start > cfg.stream_timeout:
|
||||
yield ndjson({"done": True, "error": "timeout"})
|
||||
return
|
||||
if not line.strip():
|
||||
line = line.strip()
|
||||
if not line or line.startswith(":") or not line.startswith("data:"):
|
||||
continue
|
||||
data = json.loads(line)
|
||||
if data.get("done"):
|
||||
yield ndjson({"done": True, "model": model})
|
||||
return
|
||||
delta = data.get("message", {}).get("content", "")
|
||||
payload = line[len("data:") :].strip()
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
data = json.loads(payload)
|
||||
choices = data.get("choices") or [{}]
|
||||
delta = choices[0].get("delta", {}).get("content") or ""
|
||||
if delta:
|
||||
yield ndjson({"delta": delta})
|
||||
yield ndjson({"done": True, "model": model})
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
log.warning("error while streaming from Ollama: %s", exc)
|
||||
log.warning("error while streaming from the LLM server: %s", exc)
|
||||
yield ndjson({"done": True, "error": "upstream error"})
|
||||
finally:
|
||||
stream_ctx.__exit__(None, None, None)
|
||||
|
||||
Reference in New Issue
Block a user