harden: timeouts, crash guards, UX polish
This commit is contained in:
+47
-19
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user