"""HTTP client for a local Ollama generate API. Matches the newsPipeline local_llm summarizer: POST /api/generate, GET /api/tags. No API key. ``LLM_URL`` may be a host, ``/api``, or ``/api/generate`` — we normalize to the origin. """ from __future__ import annotations import os import httpx _DEFAULT_UA = "osint-dashboard-news-summarizer" _DEFAULT_BASE = "http://127.0.0.1:11434" _GENERATE_TIMEOUT = 300.0 _TAGS_TIMEOUT = 8.0 _GENERATE_OPTIONS = { "num_predict": 4096, "temperature": 0.6, "top_p": 0.9, "top_k": 40, "num_ctx": 32768, "repeat_penalty": 1.1, } def normalize_base(url: str | None) -> str: """Strip path suffixes so LLM_URL variants share one origin.""" raw = (url or os.environ.get("LLM_URL", "") or _DEFAULT_BASE).strip() raw = raw.rstrip("/") for suffix in ("/api/generate", "/api/chat", "/api", "/v1"): if raw.endswith(suffix): raw = raw[: -len(suffix)].rstrip("/") break return raw or _DEFAULT_BASE def _headers() -> dict[str, str]: return { "User-Agent": os.environ.get("OSINT_USER_AGENT") or _DEFAULT_UA, "Accept": "application/json", } def chat(prompt: str, *, model: str, base_url: str | None = None, json_mode: bool = False) -> str: """POST /api/generate. Returns response text, or \"\" on any failure.""" if not (model or "").strip(): return "" origin = normalize_base(base_url) url = f"{origin}/api/generate" payload: dict = { "model": model.strip(), "prompt": prompt, "stream": False, "options": dict(_GENERATE_OPTIONS), } if json_mode: payload["format"] = "json" try: with httpx.Client(timeout=_GENERATE_TIMEOUT) as client: resp = client.post(url, headers=_headers(), json=payload) if resp.status_code == 401 or resp.status_code >= 500: return "" data = resp.json() text = data.get("response") if isinstance(data, dict) else None return text if isinstance(text, str) else "" except Exception: return "" def list_tags(base_url: str | None = None) -> list[str]: """GET /api/tags model names. Empty list on failure.""" origin = normalize_base(base_url) url = f"{origin}/api/tags" try: with httpx.Client(timeout=_TAGS_TIMEOUT) as client: resp = client.get(url, headers=_headers()) if resp.status_code != 200: return [] models = (resp.json() or {}).get("models") or [] names: list[str] = [] for item in models: if isinstance(item, dict): name = item.get("name") or item.get("model") or "" if name: names.append(str(name)) return names except Exception: return [] def ollama_ready(model: str, *, base_url: str | None = None) -> bool: """True when /api/tags is up and ``model`` is present (prefix match).""" if not (model or "").strip(): return False names = list_tags(base_url) if not names: return False want = model.strip() return any(n == want or n.startswith(want) for n in names)