harden: timeouts, crash guards, UX polish

This commit is contained in:
cra88y
2026-01-11 10:26:19 -06:00
parent 3072abfce8
commit 8653b98aa0
+113 -85
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')
self.max_tokens = int(os.getenv('GEMINI_MAX_TOKENS', 500)) try:
self.temperature = float(os.getenv('GEMINI_TEMPERATURE', 0.2)) 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))
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
self.secret = os.getenv('SXNG_LLM_SECRET') or hashlib.sha256(self.api_key.encode()).hexdigest() if self.api_key:
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) abort(403)
except: abort(403) except (ValueError, KeyError, AttributeError):
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,83 +154,95 @@ class SXNGPlugin(Plugin):
def post_search(self, request, search) -> EngineResults: def post_search(self, request, search) -> EngineResults:
results = EngineResults() results = EngineResults()
if not self.active or not self.api_key or search.search_query.pageno > 1: try:
return results if not self.active or not self.api_key or search.search_query.pageno > 1:
return results
raw_results = search.result_container.get_ordered_results() raw_results = search.result_container.get_ordered_results()
context_list = [f"[{i+1}] {r.get('title')}: {r.get('content')}" for i, r in enumerate(raw_results[:6])] context_list = [f"[{i+1}] {r.get('title')}: {r.get('content')}" for i, r in enumerate(raw_results[:6])]
context_str = "\n".join(context_list) context_str = "\n".join(context_list)
# Stateless Handshake # Stateless Handshake
ts = str(int(time.time())) ts = str(int(time.time()))
q_clean = search.search_query.query.strip() q_clean = search.search_query.query.strip()
sig = hashlib.sha256(f"{ts}{q_clean}{self.secret}".encode()).hexdigest() sig = hashlib.sha256(f"{ts}{q_clean}{self.secret}".encode()).hexdigest()
tk = f"{ts}.{sig}" tk = f"{ts}.{sig}"
b64_context = base64.b64encode(context_str.encode('utf-8')).decode('utf-8') b64_context = base64.b64encode(context_str.encode('utf-8')).decode('utf-8')
js_q = json.dumps(q_clean) js_q = json.dumps(q_clean)
html_payload = f''' html_payload = f'''
<style> <style>
@keyframes sxng-blink {{ 0%, 100% {{ opacity: 1; }} 50% {{ opacity: 0; }} }} @keyframes sxng-blink {{ 0%, 100% {{ opacity: 1; }} 50% {{ opacity: 0; }} }}
.sxng-cursor {{ .sxng-cursor {{
display: inline-block; width: 0.5rem; height: 1rem; display: inline-block; width: 0.5rem; height: 1rem;
background: var(--color-result-description); background: var(--color-result-description);
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;
}}
</style>
<article id="sxng-stream-box" class="answer" style="display:none; margin-bottom: 1rem;">
<p id="sxng-stream-data" style="white-space: pre-wrap; color: var(--color-result-description); font-size: 0.95rem;">
<i id="sxng-loading">Thinking...</i>
</p>
</article>
<script>
(async () => {{
const q = {js_q};
const b64 = "{b64_context}";
const tk = "{tk}";
const shell = document.getElementById('sxng-stream-box');
const data = document.getElementById('sxng-stream-data');
const container = document.getElementById('urls') || document.getElementById('main_results');
if (container && shell) {{ container.prepend(shell); shell.style.display = 'block'; }}
try {{
const ctx = new TextDecoder().decode(Uint8Array.from(atob(b64), c => c.charCodeAt(0)));
const res = await fetch('/gemini-stream', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ q: q, context: ctx, tk: tk }})
}});
if (!res.ok) {{ shell.style.display = 'none'; return; }}
const reader = res.body.getReader();
const decoder = new TextDecoder();
const cursor = document.createElement('span');
cursor.className = 'sxng-cursor';
let started = false;
while (true) {{
const {{done, value}} = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
if (chunk) {{
if (!started) {{
data.innerHTML = "";
data.appendChild(cursor);
started = true;
}}
cursor.before(chunk);
}}
}} }}
cursor.remove(); #sxng-stream-box {{
if (!started) shell.style.display = 'none'; max-height: 0;
}} catch (e) {{ console.error(e); shell.style.display = 'none'; }} overflow: hidden;
}})(); transition: max-height 0.4s ease-out;
</script> }}
''' #sxng-stream-box.sxng-open {{
search.result_container.answers.add(results.types.Answer(answer=Markup(html_payload))) max-height: 30em;
}}
</style>
<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>
</article>
<script>
(async () => {{
const q = {js_q};
const b64 = "{b64_context}";
const tk = "{tk}";
const box = document.getElementById('sxng-stream-box');
const data = document.getElementById('sxng-stream-data');
const container = document.getElementById('urls') || document.getElementById('main_results');
if (container && box) {{ container.prepend(box); }}
try {{
const ctx = new TextDecoder().decode(Uint8Array.from(atob(b64), c => c.charCodeAt(0)));
const res = await fetch('/gemini-stream', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ q: q, context: ctx, tk: tk }})
}});
if (!res.ok) {{ box.remove(); return; }}
const reader = res.body.getReader();
const decoder = new TextDecoder();
const cursor = document.createElement('span');
cursor.className = 'sxng-cursor';
let started = false;
while (true) {{
const {{done, value}} = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
if (chunk) {{
let text = chunk;
if (!started) {{
text = text.replace(/^[\s.,;:!?]+/, '');
if (!text) continue;
data.appendChild(cursor);
box.classList.add('sxng-open');
started = true;
}}
cursor.before(text);
}}
}}
cursor.remove();
if (!started) box.remove();
}} catch (e) {{ console.error(e); box.remove(); }}
}})();
</script>
'''
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