diff --git a/README.md b/README.md index db4cedb..1b6e8cb 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,25 @@ -# SearXNG Gemini Stream +# SearXNG Gemini & OpenRouter Stream -A SearXNG plugin that streams an AI response using results as grounding context to an Answer box at the top of results. +A SearXNG plugin that streams AI responses using search results as grounding context. Supports Google Gemini and OpenAI-compatible providers (OpenRouter, Ollama, etc.). ## Configuration Set the following environment variables: -- `GEMINI_API_KEY`: Your Google Gemini API key. -- `GEMINI_MODEL`: (Optional) Defaults to `gemini-3-flash-preview`. -- `GEMINI_MAX_TOKENS`: (Optional) Defaults to `500`. -- `GEMINI_TEMPERATURE`: (Optional) Defaults to `0.2`. -### settings.yml -Add this to your SearXNG configuration file to enable the plugin: +### General +- `LLM_PROVIDER`: `gemini` (default) or `openrouter`. +- `GEMINI_MAX_TOKENS`: Defaults to `500`. +- `GEMINI_TEMPERATURE`: Defaults to `0.2`. -```yaml -plugins: - - name: gemini_flash - active: true -``` +### Google Gemini +- `GEMINI_API_KEY`: Your Google AI API key. +- `GEMINI_MODEL`: Defaults to `gemini-1.5-flash`. + +### OpenRouter / OpenAI / Ollama +- `OPENROUTER_API_KEY`: Your API key. +- `OPENROUTER_MODEL`: e.g., `meta-llama/llama-3-8b-instruct:free`. +- `OPENROUTER_BASE_URL`: Defaults to `openrouter.ai`. (Change to `localhost:11434` for Ollama). ## Installation -Place `gemini_flash.py` into the `searx/plugins` directory of your instance. +Place `gemini_flash.py` into the `searx/plugins` directory of your instance and enable it in `settings.yml`. \ No newline at end of file diff --git a/gemini_flash.py b/gemini_flash.py index 56c1b28..95138a7 100644 --- a/gemini_flash.py +++ b/gemini_flash.py @@ -18,10 +18,12 @@ class SXNGPlugin(Plugin): description=gettext("Live AI search answers using Google Gemini Flash"), preference_section="general", ) - self.api_key = os.getenv('GEMINI_API_KEY') - self.model = os.getenv('GEMINI_MODEL', 'gemini-3-flash-preview') + self.provider = os.getenv('LLM_PROVIDER', 'gemini').lower() + self.api_key = os.getenv('OPENROUTER_API_KEY') if self.provider == 'openrouter' else os.getenv('GEMINI_API_KEY') + self.model = os.getenv('GEMINI_MODEL', 'gemini-1.5-flash') if self.provider == 'gemini' else os.getenv('OPENROUTER_MODEL', 'google/gemini-2.0-flash-exp:free') self.max_tokens = int(os.getenv('GEMINI_MAX_TOKENS', 500)) self.temperature = float(os.getenv('GEMINI_TEMPERATURE', 0.2)) + self.base_url = os.getenv('OPENROUTER_BASE_URL', 'openrouter.ai') def init(self, app): @app.route('/gemini-stream', methods=['POST']) @@ -33,26 +35,26 @@ class SXNGPlugin(Plugin): if not self.api_key or not q: return Response("Error: Missing Key or Query", status=400) - def generate(): + prompt = ( + f"SYSTEM: Answer USER QUERY by integrating SEARCH RESULTS with expert knowledge.\n" + f"HIERARCHY: Use RESULTS for facts/data. Use KNOWLEDGE for context/synthesis.\n" + f"CONSTRAINTS: <4 sentences | Dense information | Complete thoughts.\n" + f"FALLBACK: If results are empty, answer from knowledge but note the lack of sources.\n\n" + f"SEARCH RESULTS:\n{context_text}\n\n" + f"USER QUERY: {q}\n\n" + f"ANSWER:" + ) + + def generate_gemini(): host = "generativelanguage.googleapis.com" - path = f"/v1beta/models/{self.model}:streamGenerateContent?key={self.api_key}" + path = f"/v1/models/{self.model}:streamGenerateContent?key={self.api_key}" try: conn = http.client.HTTPSConnection(host, context=ssl.create_default_context()) - prompt = ( - f"SYSTEM: Answer USER QUERY by integrating SEARCH RESULTS with expert knowledge.\n" - f"HIERARCHY: Use RESULTS for facts/data. Use KNOWLEDGE for context/synthesis.\n" - f"CONSTRAINTS: <4 sentences | Dense information | Complete thoughts.\n" - f"FALLBACK: If results are empty, answer from knowledge but note the lack of sources.\n\n" - f"SEARCH RESULTS:\n{context_text}\n\n" - f"USER QUERY: {q}\n\n" - f"ANSWER:" - ) 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"}) res = conn.getresponse() if res.status != 200: - yield f" [Error: {res.status} {res.reason} - {res.read().decode('utf-8')}]" return decoder = json.JSONDecoder() @@ -81,10 +83,50 @@ class SXNGPlugin(Plugin): break conn.close() - except Exception as e: - yield f" [Error: {str(e)}]" + except Exception: + pass - return Response(generate(), mimetype='text/plain', headers={'X-Accel-Buffering': 'no'}) + def generate_openrouter(): + try: + conn = http.client.HTTPSConnection(self.base_url, context=ssl.create_default_context()) + payload = { + "model": self.model, + "messages": [{"role": "user", "content": prompt}], + "stream": True, + "max_tokens": self.max_tokens, + "temperature": self.temperature + } + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/cra88y/searxng-stream-gemini", + "X-Title": "SearXNG Gemini Stream" + } + conn.request("POST", "/api/v1/chat/completions", body=json.dumps(payload), headers=headers) + res = conn.getresponse() + if res.status != 200: return + + buffer = "" + while True: + chunk = res.read(1024) + if not chunk: break + buffer += chunk.decode('utf-8') + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line.startswith("data: "): + data_str = line[6:].strip() + if data_str == "[DONE]": return + try: + data_json = json.loads(data_str) + content = data_json.get("choices", [{}])[0].get("delta", {}).get("content", "") + if content: yield content + except: pass + conn.close() + except Exception: pass + + generator = generate_openrouter if self.provider == 'openrouter' else generate_gemini + return Response(generator(), mimetype='text/plain', headers={'X-Accel-Buffering': 'no'}) return True def post_search(self, request, search) -> EngineResults: @@ -100,38 +142,40 @@ class SXNGPlugin(Plugin): js_q = json.dumps(search.search_query.query) html_payload = f''' - + ''' diff --git a/test_standalone.py b/test_standalone.py index 29be995..986324b 100644 --- a/test_standalone.py +++ b/test_standalone.py @@ -91,9 +91,9 @@ def index(): -

Gemini Plugin Test

+

LLM Plugin Test

+

Provider: {plugin.provider} | Model: {plugin.model}

Testing query: {MockSearch.search_query.query}

-

Try: "tell me a joke" | Try: "explain quantum physics"


{injection_html} @@ -110,13 +110,14 @@ class PluginTestCase(unittest.TestCase): def test_html_injection(self): response = self.app.get('/') content = response.data.decode('utf-8') - self.assertIn('
0) + print(f"\n[Test] Received {len(data)} bytes from {plugin.provider}") if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file