"""Horus-OSINT online API — free Hugging Face Space (Gradio SDK). WHY THIS SHAPE -------------- Two constraints forced it: 1. **HF serverless cannot serve this model.** ``mahmoudalyosify/Horus-OSINT`` ships a single ``.gguf`` file and HF reports ``inferenceProviderMapping: {}`` for it. GGUF is llama.cpp's format; serverless inference needs transformers weights. No API token changes that. 2. **Docker Spaces now require a paid plan.** The Gradio SDK is still free, and a Gradio Space is just a Python process behind a web server — so we run llama.cpp *inside* it via ``llama-cpp-python`` and mount our own FastAPI routes alongside the Gradio UI. The result is an **OpenAI-compatible** ``/v1/chat/completions`` endpoint on a free Space, which is exactly what ``horus_brain/remote_provider.py`` already speaks. Point HORUS at it and the fine-tune runs online, reachable from a phone. HONEST LIMITS ------------- Free Spaces are CPU-only (2 vCPU). An 8B model at Q4 runs at roughly 1–3 tokens per second: a short answer takes tens of seconds, a full report several minutes. Moving a model to a server does not make a CPU faster. Free Spaces also sleep when idle and reload on wake, so the first request after a quiet spell is slow — HORUS surfaces that as "starting up" rather than an error. """ from __future__ import annotations import os import time import uuid from threading import Lock from typing import Any import gradio as gr from fastapi import APIRouter, HTTPException, Request from huggingface_hub import hf_hub_download from pydantic import BaseModel, Field # --------------------------------------------------------------------------- # # Configuration — override in the Space's Variables and Secrets # --------------------------------------------------------------------------- # REPO_ID = os.getenv("MODEL_REPO", "mahmoudalyosify/Horus-OSINT") MODEL_FILE = os.getenv("MODEL_FILE", "llama-3-8b-instruct.Q4_K_M.gguf") MODEL_ALIAS = os.getenv("MODEL_ALIAS", "horus-osint") # Set this as a SECRET. Without it the endpoint is open to anyone with the URL. API_KEY = os.getenv("API_KEY", "").strip() # Free Spaces have limited RAM; a large context window costs memory that the # weights need. 4096 is comfortable for a briefing prompt. CTX = int(os.getenv("CTX_SIZE", "4096")) THREADS = int(os.getenv("THREADS", "2")) _llm: Any = None # llama.cpp is not safe for concurrent generation on one context, and a free # Space has no headroom for a second copy. Serialising is correct here. _lock = Lock() def get_llm() -> Any: """Load the model on first use. Deferred rather than loaded at import so the Space finishes booting and starts answering health checks before the ~5 GB download begins. """ global _llm if _llm is None: from llama_cpp import Llama path = hf_hub_download(repo_id=REPO_ID, filename=MODEL_FILE) _llm = Llama( model_path=path, n_ctx=CTX, n_threads=THREADS, # The GGUF carries a Llama-3 chat template; llama-cpp-python applies # it, so callers send plain messages and never hand-build prompts. chat_format="llama-3", verbose=False, ) return _llm # --------------------------------------------------------------------------- # # OpenAI-compatible API # --------------------------------------------------------------------------- # class Message(BaseModel): role: str content: str class ChatRequest(BaseModel): model: str = MODEL_ALIAS messages: list[Message] max_tokens: int = Field(default=512, ge=1, le=2048) temperature: float = Field(default=0.2, ge=0.0, le=2.0) stream: bool = False # Routes live on a router, not on an app of our own. On a Gradio SDK Space, # Gradio builds and owns the FastAPI application; creating a second one and # serving it with uvicorn is what produced # [Errno 98] address already in use ('0.0.0.0', 7861) # — two servers racing for the same port. The router is attached to Gradio's app # after launch instead, so there is exactly one server and Hugging Face manages # its lifecycle. api = APIRouter() def _check_key(request: Request) -> None: """Bearer check. Skipped only when no key is configured. Raises: HTTPException: 401 when a key is set and the header does not match. """ if not API_KEY: return header = request.headers.get("authorization", "") if header.removeprefix("Bearer ").strip() != API_KEY: raise HTTPException(status_code=401, detail="invalid api key") @api.get("/health") def health() -> dict[str, Any]: """Liveness. Deliberately does not touch the model, so it answers instantly while the weights are still downloading.""" return {"status": "ok", "model": MODEL_ALIAS, "loaded": _llm is not None} @api.get("/v1/models") def list_models(request: Request) -> dict[str, Any]: """Model list, for clients that discover before calling.""" _check_key(request) return {"object": "list", "data": [{"id": MODEL_ALIAS, "object": "model"}]} @api.post("/v1/chat/completions") def chat_completions(payload: ChatRequest, request: Request) -> dict[str, Any]: """OpenAI-compatible completion. Streaming is accepted in the request and answered non-streamed: on a CPU Space the whole generation is slow enough that partial delivery buys little, and a correct SSE implementation here would be more failure surface than it is worth. """ _check_key(request) llm = get_llm() with _lock: result = llm.create_chat_completion( messages=[m.model_dump() for m in payload.messages], max_tokens=payload.max_tokens, temperature=payload.temperature, ) choice = result["choices"][0] return { "id": f"chatcmpl-{uuid.uuid4().hex[:24]}", "object": "chat.completion", "created": int(time.time()), "model": MODEL_ALIAS, "choices": [ { "index": 0, "message": { "role": "assistant", "content": choice["message"]["content"], }, "finish_reason": choice.get("finish_reason", "stop"), } ], "usage": result.get("usage", {}), } # --------------------------------------------------------------------------- # # Gradio UI — also what keeps the Space alive under the free SDK # --------------------------------------------------------------------------- # def _reply(message: str, history: list[Any]) -> str: llm = get_llm() messages = [{"role": "system", "content": "You are HORUS, an OSINT and geopolitical analyst."}] for turn in history or []: if isinstance(turn, dict): messages.append({"role": turn.get("role", "user"), "content": turn.get("content", "")}) messages.append({"role": "user", "content": message}) with _lock: out = llm.create_chat_completion(messages=messages, max_tokens=512, temperature=0.2) return out["choices"][0]["message"]["content"] with gr.Blocks(title="Horus-OSINT API") as demo: gr.Markdown( f""" # Horus-OSINT — online API Serving `{REPO_ID}` over an **OpenAI-compatible** endpoint. **Endpoint:** `POST /v1/chat/completions` · **Model name:** `{MODEL_ALIAS}` Free CPU hardware: expect roughly 1–3 tokens/second, and a slow first request after the Space has been idle. """ ) gr.ChatInterface(_reply, type="messages") # Launch, then attach. `prevent_thread_lock=True` makes launch() return the # FastAPI application Gradio built instead of blocking, so the OpenAI routes can # be registered on the *same* app and served by the *same* process. # # No port is named anywhere: Gradio reads GRADIO_SERVER_PORT, which Hugging Face # sets. Hardcoding one is what collided in the first place. if __name__ == "__main__": fastapi_app, _local_url, _share_url = demo.queue().launch( server_name="0.0.0.0", prevent_thread_lock=True, show_api=False, # Gradio 5 defaults to server-side rendering, which spawns a Node # process beside Python. This Space is an API with a token UI attached, # so SSR buys nothing and only adds a second runtime that can fail — # the "Stopping Node.js server..." line in the logs is that process. ssr_mode=False, ) # Register, then move to the front. Starlette matches routes in order and # Gradio owns a catch-all for its UI, so appended routes would never be # reached — /v1/chat/completions would quietly return the Gradio page # instead of a completion. first_new = len(fastapi_app.router.routes) fastapi_app.include_router(api) added = fastapi_app.router.routes[first_new:] del fastapi_app.router.routes[first_new:] fastapi_app.router.routes[0:0] = added # launch() returned instead of blocking, so hold the process open. Gradio's # server is already running in its own thread. demo.block_thread()