harden: timeouts, crash guards, UX polish

This commit is contained in:
cra88y
2026-01-11 10:26:19 -06:00
parent 3072abfce8
commit 8653b98aa0
+47 -19
View File
@@ -1,4 +1,4 @@
import json, http.client, ssl, os, logging, base64, secrets, time, hashlib import json, http.client, ssl, os, logging, base64, time, hashlib
from flask import Response, request, abort from flask import Response, request, abort
from searx.plugins import Plugin, PluginInfo from searx.plugins import Plugin, PluginInfo
from searx.result_types import EngineResults from searx.result_types import EngineResults
@@ -7,6 +7,10 @@ from markupsafe import Markup
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Constants
TOKEN_EXPIRY_SEC = 60
CONNECTION_TIMEOUT_SEC = 30
class SXNGPlugin(Plugin): class SXNGPlugin(Plugin):
id = "gemini_flash" id = "gemini_flash"
@@ -21,11 +25,21 @@ class SXNGPlugin(Plugin):
self.provider = os.getenv('LLM_PROVIDER', 'openrouter').lower() self.provider = os.getenv('LLM_PROVIDER', 'openrouter').lower()
self.api_key = os.getenv('OPENROUTER_API_KEY') if self.provider == 'openrouter' else os.getenv('GEMINI_API_KEY') self.api_key = os.getenv('OPENROUTER_API_KEY') if self.provider == 'openrouter' else os.getenv('GEMINI_API_KEY')
self.model = os.getenv('GEMINI_MODEL', 'gemma-3-27b-it') if self.provider == 'gemini' else os.getenv('OPENROUTER_MODEL', 'google/gemma-3-27b-it:free') self.model = os.getenv('GEMINI_MODEL', 'gemma-3-27b-it') if self.provider == 'gemini' else os.getenv('OPENROUTER_MODEL', 'google/gemma-3-27b-it:free')
try:
self.max_tokens = int(os.getenv('GEMINI_MAX_TOKENS', 500)) self.max_tokens = int(os.getenv('GEMINI_MAX_TOKENS', 500))
except ValueError:
self.max_tokens = 500
try:
self.temperature = float(os.getenv('GEMINI_TEMPERATURE', 0.2)) self.temperature = float(os.getenv('GEMINI_TEMPERATURE', 0.2))
except ValueError:
self.temperature = 0.2
self.base_url = os.getenv('OPENROUTER_BASE_URL', 'openrouter.ai') self.base_url = os.getenv('OPENROUTER_BASE_URL', 'openrouter.ai')
# Stable secret for multi-worker environments # Stable secret for multi-worker environments
if self.api_key:
self.secret = os.getenv('SXNG_LLM_SECRET') or hashlib.sha256(self.api_key.encode()).hexdigest() self.secret = os.getenv('SXNG_LLM_SECRET') or hashlib.sha256(self.api_key.encode()).hexdigest()
else:
self.secret = os.getenv('SXNG_LLM_SECRET', '')
logger.warning("Gemini Flash plugin: No API key configured, plugin will be inactive")
def init(self, app): def init(self, app):
@app.route('/gemini-stream', methods=['POST']) @app.route('/gemini-stream', methods=['POST'])
@@ -38,9 +52,10 @@ class SXNGPlugin(Plugin):
ts, sig = token.split('.', 1) ts, sig = token.split('.', 1)
query_clean = q.strip() query_clean = q.strip()
expected = hashlib.sha256(f"{ts}{query_clean}{self.secret}".encode()).hexdigest() expected = hashlib.sha256(f"{ts}{query_clean}{self.secret}".encode()).hexdigest()
if sig != expected or (time.time() - float(ts)) > 60: if sig != expected or (time.time() - float(ts)) > TOKEN_EXPIRY_SEC:
abort(403)
except (ValueError, KeyError, AttributeError):
abort(403) abort(403)
except: abort(403)
context_text = data.get('context', '') context_text = data.get('context', '')
if not self.api_key or not q: if not self.api_key or not q:
@@ -60,7 +75,7 @@ class SXNGPlugin(Plugin):
host = "generativelanguage.googleapis.com" host = "generativelanguage.googleapis.com"
path = f"/v1/models/{self.model}:streamGenerateContent?key={self.api_key}" path = f"/v1/models/{self.model}:streamGenerateContent?key={self.api_key}"
try: try:
conn = http.client.HTTPSConnection(host, context=ssl.create_default_context()) conn = http.client.HTTPSConnection(host, timeout=CONNECTION_TIMEOUT_SEC, context=ssl.create_default_context())
payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"maxOutputTokens": self.max_tokens, "temperature": self.temperature}} payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"maxOutputTokens": self.max_tokens, "temperature": self.temperature}}
conn.request("POST", path, body=json.dumps(payload), headers={"Content-Type": "application/json"}) conn.request("POST", path, body=json.dumps(payload), headers={"Content-Type": "application/json"})
res = conn.getresponse() res = conn.getresponse()
@@ -93,7 +108,7 @@ class SXNGPlugin(Plugin):
def generate_openrouter(): def generate_openrouter():
try: try:
conn = http.client.HTTPSConnection(self.base_url, context=ssl.create_default_context()) conn = http.client.HTTPSConnection(self.base_url, timeout=CONNECTION_TIMEOUT_SEC, context=ssl.create_default_context())
payload = { payload = {
"model": self.model, "model": self.model,
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
@@ -104,8 +119,8 @@ class SXNGPlugin(Plugin):
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
"HTTP-Referer": "https://github.com/cra88y/searxng-stream-gemini", "HTTP-Referer": "https://github.com/searxng/searxng",
"X-Title": "SearXNG Stream" "X-Title": "SearXNG LLM Plugin"
} }
conn.request("POST", "/api/v1/chat/completions", body=json.dumps(payload), headers=headers) conn.request("POST", "/api/v1/chat/completions", body=json.dumps(payload), headers=headers)
res = conn.getresponse() res = conn.getresponse()
@@ -128,7 +143,8 @@ class SXNGPlugin(Plugin):
obj, _ = decoder.raw_decode(data_str) obj, _ = decoder.raw_decode(data_str)
content = obj.get("choices", [{}])[0].get("delta", {}).get("content", "") content = obj.get("choices", [{}])[0].get("delta", {}).get("content", "")
if content: yield content if content: yield content
except: pass except json.JSONDecodeError:
pass
conn.close() conn.close()
except Exception as e: logger.error(f"OpenRouter Stream Exception: {e}") except Exception as e: logger.error(f"OpenRouter Stream Exception: {e}")
@@ -138,6 +154,7 @@ class SXNGPlugin(Plugin):
def post_search(self, request, search) -> EngineResults: def post_search(self, request, search) -> EngineResults:
results = EngineResults() results = EngineResults()
try:
if not self.active or not self.api_key or search.search_query.pageno > 1: if not self.active or not self.api_key or search.search_query.pageno > 1:
return results return results
@@ -163,22 +180,28 @@ class SXNGPlugin(Plugin):
margin-left: 2px; vertical-align: middle; margin-left: 2px; vertical-align: middle;
animation: sxng-blink 1s step-end infinite; animation: sxng-blink 1s step-end infinite;
}} }}
#sxng-stream-box {{
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease-out;
}}
#sxng-stream-box.sxng-open {{
max-height: 30em;
}}
</style> </style>
<article id="sxng-stream-box" class="answer" style="display:none; margin-bottom: 1rem;"> <article id="sxng-stream-box" class="answer" style="margin-bottom: 1rem;">
<p id="sxng-stream-data" style="white-space: pre-wrap; color: var(--color-result-description); font-size: 0.95rem;"> <p id="sxng-stream-data" style="white-space: pre-wrap; color: var(--color-result-description); font-size: 0.95rem;"></p>
<i id="sxng-loading">Thinking...</i>
</p>
</article> </article>
<script> <script>
(async () => {{ (async () => {{
const q = {js_q}; const q = {js_q};
const b64 = "{b64_context}"; const b64 = "{b64_context}";
const tk = "{tk}"; const tk = "{tk}";
const shell = document.getElementById('sxng-stream-box'); const box = document.getElementById('sxng-stream-box');
const data = document.getElementById('sxng-stream-data'); const data = document.getElementById('sxng-stream-data');
const container = document.getElementById('urls') || document.getElementById('main_results'); const container = document.getElementById('urls') || document.getElementById('main_results');
if (container && shell) {{ container.prepend(shell); shell.style.display = 'block'; }} if (container && box) {{ container.prepend(box); }}
try {{ try {{
const ctx = new TextDecoder().decode(Uint8Array.from(atob(b64), c => c.charCodeAt(0))); const ctx = new TextDecoder().decode(Uint8Array.from(atob(b64), c => c.charCodeAt(0)));
@@ -188,7 +211,7 @@ class SXNGPlugin(Plugin):
body: JSON.stringify({{ q: q, context: ctx, tk: tk }}) body: JSON.stringify({{ q: q, context: ctx, tk: tk }})
}}); }});
if (!res.ok) {{ shell.style.display = 'none'; return; }} if (!res.ok) {{ box.remove(); return; }}
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
@@ -202,19 +225,24 @@ class SXNGPlugin(Plugin):
const chunk = decoder.decode(value); const chunk = decoder.decode(value);
if (chunk) {{ if (chunk) {{
let text = chunk;
if (!started) {{ if (!started) {{
data.innerHTML = ""; text = text.replace(/^[\s.,;:!?]+/, '');
if (!text) continue;
data.appendChild(cursor); data.appendChild(cursor);
box.classList.add('sxng-open');
started = true; started = true;
}} }}
cursor.before(chunk); cursor.before(text);
}} }}
}} }}
cursor.remove(); cursor.remove();
if (!started) shell.style.display = 'none'; if (!started) box.remove();
}} catch (e) {{ console.error(e); shell.style.display = 'none'; }} }} catch (e) {{ console.error(e); box.remove(); }}
}})(); }})();
</script> </script>
''' '''
search.result_container.answers.add(results.types.Answer(answer=Markup(html_payload))) search.result_container.answers.add(results.types.Answer(answer=Markup(html_payload)))
except Exception as e:
logger.error(f"Gemini Flash plugin error: {e}")
return results return results