#!/usr/bin/env python3 """ OpenAI-compatible facade in front of Ollama. 1. Injects a SearXNG-backed `web_search` tool into every chat/completions request 2. Executes web_search tool calls server-side (non-streaming requests) and feeds the results back to the model before answering the client 3. All other tool calls (the client's own tools) are returned to the client untouched — the client runs its own tool loop 4. Streaming requests are passed straight through to Ollama (no probe), so the client keeps native SSE and thinking output. In that case, if the model calls web_search the client receives that tool_call. Ollama ignores the Authorization header (no API key), so clients may send any key. """ from flask import Flask, request, Response, stream_with_context import requests import json import sys import os app = Flask(__name__) sys.stdout.reconfigure(line_buffering=True) sys.stderr.reconfigure(line_buffering=True) BACKEND_URL = os.environ.get("BACKEND_URL", "http://vllm:8000") BACKEND_API_KEY = os.environ.get("BACKEND_API_KEY", "") SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://host.docker.internal:8889") SEARXNG_RESULTS = int(os.environ.get("SEARXNG_RESULTS", "5")) MAX_SEARCH_ITER = int(os.environ.get("MAX_SEARCH_ITER", "10")) SEARXNG_TOOL = { "type": "function", "function": { "name": "web_search", "description": ( "Search the web for current information. Use this whenever you need " "up-to-date facts, recent events, or anything that may have changed " "since your training cutoff. Do not guess — call web_search instead." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "The search query string" } }, "required": ["query"] } } } SEARCH_SYSTEM_MSG = ( "You have access to a web_search tool. " "Use it whenever the user asks about current events, real-time data, recent news, " "live prices, weather, sports scores, or anything else that may have changed since " "your training cutoff. Do not say you cannot access the internet — call web_search instead." ) def searxng_search(query): """Execute a search against SearXNG and return a formatted result string.""" try: print(f"[SEARXNG] Searching: {query}") resp = requests.get( f"{SEARXNG_URL}/search", params={"q": query, "format": "json", "categories": "general"}, timeout=15 ) resp.raise_for_status() data = resp.json() results = data.get("results", [])[:SEARXNG_RESULTS] if not results: return f"No results found for: {query}" lines = [f"Search results for: {query}\n"] for i, r in enumerate(results, 1): title = r.get("title", "No title") url = r.get("url", "") content = r.get("content", "").strip() lines.append(f"{i}. {title}") if url: lines.append(f" URL: {url}") if content: lines.append(f" {content}") lines.append("") return "\n".join(lines).strip() except Exception as e: print(f"[ERROR] SearXNG search failed: {e}") return f"Search failed: {e}" def inject_search(json_data): """Inject web_search tool and system prompt nudge if not already present.""" tools = json_data.setdefault('tools', []) names = {t.get('function', {}).get('name') for t in tools if isinstance(t, dict)} if 'web_search' not in names: tools.append(SEARXNG_TOOL) print("[INJECTED] Added web_search tool") messages = json_data.setdefault('messages', []) already = any('web_search' in str(m.get('content') or '') for m in messages if m.get('role') == 'system') if not already: if messages and messages[0].get('role') == 'system': # Append to the existing first system message so we stay at position 0 messages[0]['content'] = str(messages[0]['content']) + '\n\n' + SEARCH_SYSTEM_MSG print("[INJECTED] Appended web_search to existing system prompt") else: # No system message yet — prepend one at position 0 messages.insert(0, {"role": "system", "content": SEARCH_SYSTEM_MSG}) print("[INJECTED] Added web_search system prompt at position 0") def call_backend_nonstream(json_data, headers): """Call backend with streaming off, return parsed JSON or raise.""" data = dict(json_data) data['stream'] = False data.pop('stream_options', None) body = json.dumps(data).encode('utf-8') hdrs = {k: v for k, v in headers.items() if k.lower() != 'host'} if BACKEND_API_KEY: hdrs['Authorization'] = f'Bearer {BACKEND_API_KEY}' hdrs['Content-Length'] = str(len(body)) hdrs['Content-Type'] = 'application/json' resp = requests.post(f"{BACKEND_URL}/v1/chat/completions", headers=hdrs, data=body, timeout=600) resp.raise_for_status() return resp.json() def call_backend_passthrough(json_data, headers): """Forward request to backend as-is (preserving streaming), return raw Response.""" body = json.dumps(json_data).encode('utf-8') hdrs = {k: v for k, v in headers.items() if k.lower() != 'host'} if BACKEND_API_KEY: hdrs['Authorization'] = f'Bearer {BACKEND_API_KEY}' hdrs['Content-Length'] = str(len(body)) hdrs['Content-Type'] = 'application/json' sess = requests.Session() sess.headers.update(hdrs) resp = sess.post(f"{BACKEND_URL}/v1/chat/completions", data=body, stream=True, timeout=(10, 600)) def generate(): count = 0 try: for line in resp.iter_lines(): if line: count += 1 yield line + b'\n\n' print(f"[STREAM] Completed: {count} SSE lines forwarded") except Exception as e: print(f"[STREAM] ERROR after {count} lines: {e}") import traceback; traceback.print_exc() out_headers = {k: v for k, v in resp.headers.items() if k.lower() in {'content-type', 'cache-control', 'x-accel-buffering'}} return Response(generate(), status=resp.status_code, headers=out_headers) def run_loop(json_data, headers): """ Non-streaming path. Calls the model, handles web_search calls transparently, returns to client when the model produces non-search output. If the model calls the client's own tools alongside or instead of web_search, those are returned to the client immediately. """ is_streaming = json_data.get('stream', False) internal = dict(json_data) internal['messages'] = list(json_data.get('messages', [])) searches = 0 empty = 0 for iteration in range(MAX_SEARCH_ITER): try: completion = call_backend_nonstream(internal, headers) except Exception as e: print(f"[ERROR] Backend call failed: {e}") import traceback; traceback.print_exc() return Response( json.dumps({'error': {'message': str(e), 'type': 'proxy_error'}}).encode(), status=502, headers={'Content-Type': 'application/json'} ) choice = completion.get('choices', [{}])[0] message = choice.get('message', {}) tool_calls = message.get('tool_calls') or [] search_calls = [tc for tc in tool_calls if tc.get('function', {}).get('name') == 'web_search'] client_calls = [tc for tc in tool_calls if tc.get('function', {}).get('name') != 'web_search'] if not search_calls: print(f"[LOOP] Done after {iteration} search iteration(s)") if client_calls: print(f"[LOOP] Returning {len(client_calls)} client tool call(s) to client") if iteration == 0 and is_streaming: print("[LOOP] No search needed, passing through natively") return call_backend_passthrough(json_data, headers) if is_streaming: return _stream_completion(completion) return Response(json.dumps(completion).encode(), status=200, headers={'Content-Type': 'application/json'}) # Model wants to search — execute and loop print(f"[LOOP] Iteration {iteration}: {len(search_calls)} search call(s), " f"{len(client_calls)} client call(s)") asst = {"role": "assistant", "tool_calls": tool_calls} if message.get('content'): asst['content'] = message['content'] internal['messages'].append(asst) for tc in search_calls: try: args = json.loads(tc.get('function', {}).get('arguments', '{}')) except Exception: args = {} result = searxng_search(args.get('query', '')) searches += 1 if result.startswith(("No results found", "Search failed")): empty += 1 print(f"[LOOP] Search returned {len(result)} chars") internal['messages'].append({ "role": "tool", "tool_call_id": tc.get('id', 'search_result'), "content": result }) # Client tool calls that arrive alongside a search cannot be executed # here — stub them so the model does not stall waiting for results for tc in client_calls: name = tc.get('function', {}).get('name', 'unknown') internal['messages'].append({ "role": "tool", "tool_call_id": tc.get('id', 'stub'), "content": f"[{name} result will be provided by the user's environment]" }) print(f"[ERROR] Exceeded max search iterations ({searches} searches, {empty} empty)") hint = " — SearXNG returned no usable results; check the SearXNG engines" if empty else "" return Response( json.dumps({'error': { 'message': f'Exceeded max search iterations ({searches} searches, {empty} empty){hint}', 'type': 'proxy_error'}}).encode(), status=500, headers={'Content-Type': 'application/json'} ) def _stream_completion(completion): """Re-stream a non-streaming completion as SSE.""" choice = completion.get('choices', [{}])[0] message = choice.get('message', {}) content = message.get('content') or '' reasoning = message.get('reasoning') or message.get('reasoning_content') or '' tool_calls = message.get('tool_calls') or [] finish_reason = choice.get('finish_reason', 'stop') cid = completion.get('id', 'chatcmpl-search') model = completion.get('model', 'unknown') usage = completion.get('usage') def generate(): yield _sse({'id': cid, 'object': 'chat.completion.chunk', 'model': model, 'choices': [{'index': 0, 'delta': {'role': 'assistant', 'content': ''}, 'finish_reason': None}]}) if tool_calls: for i, tc in enumerate(tool_calls): yield _sse({'id': cid, 'object': 'chat.completion.chunk', 'model': model, 'choices': [{'index': 0, 'delta': {'tool_calls': [{ 'index': i, 'id': tc.get('id', ''), 'type': 'function', 'function': { 'name': tc.get('function', {}).get('name', ''), 'arguments': tc.get('function', {}).get('arguments', '') } }]}, 'finish_reason': None}]}) else: if reasoning: for i in range(0, len(reasoning), 64): yield _sse({'id': cid, 'object': 'chat.completion.chunk', 'model': model, 'choices': [{'index': 0, 'delta': {'reasoning_content': reasoning[i:i + 64]}, 'finish_reason': None}]}) for i in range(0, max(len(content), 1), 64): yield _sse({'id': cid, 'object': 'chat.completion.chunk', 'model': model, 'choices': [{'index': 0, 'delta': {'content': content[i:i + 64]}, 'finish_reason': None}]}) final = {'id': cid, 'object': 'chat.completion.chunk', 'model': model, 'choices': [{'index': 0, 'delta': {}, 'finish_reason': finish_reason}]} if usage: final['usage'] = usage yield _sse(final) yield b"data: [DONE]\n\n" return Response(stream_with_context(generate()), status=200, headers={'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) def _sse(obj): return f"data: {json.dumps(obj)}\n\n".encode() def forward(method, url, headers, data): """Transparently forward a request and stream the response back.""" hdrs = {k: v for k, v in headers.items() if k.lower() != 'host'} if BACKEND_API_KEY: hdrs['Authorization'] = f'Bearer {BACKEND_API_KEY}' sess = requests.Session() sess.headers.update(hdrs) resp = sess.request(method=method, url=url, data=data, stream=True, timeout=(10, 600)) def generate(): for line in resp.iter_lines(): if line: yield line + b'\n\n' out_headers = {k: v for k, v in resp.headers.items() if k.lower() in {'content-type', 'cache-control', 'x-accel-buffering'}} return Response(generate(), status=resp.status_code, headers=out_headers) @app.route('/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) def proxy(path): url = f"{BACKEND_URL}/{path}" headers = dict(request.headers) headers.pop('Host', None) data = request.get_data() if request.method == 'POST' and '/chat/completions' in path: try: json_data = json.loads(data) client_tools = json_data.get('tools', []) is_streaming = json_data.get('stream', False) print(f"[DEBUG] Request keys: {list(json_data.keys())}, " f"tools={len(client_tools)}, stream={is_streaming}") inject_search(json_data) if is_streaming: print("[FASTPATH] Streaming request — skipping probe, passing through natively") return call_backend_passthrough(json_data, headers) return run_loop(json_data, headers) except Exception as e: print(f"[ERROR] {e}") import traceback; traceback.print_exc() return Response( json.dumps({'error': {'message': str(e), 'type': 'proxy_error'}}).encode(), status=500, headers={'Content-Type': 'application/json'} ) # Non-chat requests — forward directly try: return forward(request.method, url, headers, data) except Exception as e: print(f"[ERROR] Request failed: {e}") return Response( json.dumps({'error': {'message': str(e)}}).encode(), status=500, headers={'Content-Type': 'application/json'} ) if __name__ == '__main__': print("Starting PASSTHROUGH+SEARCH proxy (Ollama backend)") print(f"Forwarding to: {BACKEND_URL}") print(f"SearXNG: {SEARXNG_URL} (max {SEARXNG_RESULTS} results per search)") app.run(host='0.0.0.0', port=11536, threaded=True)