Source code for curate_ipsum.synthesis.local_llm
"""
Local LLM client: Ollama backend.
Connects to a locally running Ollama instance at http://localhost:11434.
Default model: codellama:7b.
Decision: D-012 — abstract LLM client with cloud/local/mock backends.
"""
from __future__ import annotations
import logging
import re
from curate_ipsum.synthesis.llm_client import LLMClient
LOG = logging.getLogger("synthesis.local_llm")
try:
import httpx
except ImportError:
httpx = None # type: ignore[assignment]
[docs]
class LocalLLMClient(LLMClient):
"""
Local LLM backend using Ollama's HTTP API.
Requires Ollama to be running locally: https://ollama.ai
Default model: codellama:7b (good for code generation, runs on 8GB+ GPU).
"""
def __init__(
self,
base_url: str = "http://localhost:11434",
model: str = "codellama:7b",
timeout: float = 120.0,
) -> None:
if httpx is None:
raise ImportError("httpx is required for local LLM. Install with: pip install 'curate-ipsum[synthesis]'")
self._base_url = base_url
self._model = model
self._client = httpx.AsyncClient(
base_url=base_url,
timeout=timeout,
)
self._available: bool | None = None
[docs]
async def is_available(self) -> bool:
"""Check if Ollama is running and the model is available."""
if self._available is not None:
return self._available
try:
resp = await self._client.get("/api/tags")
if resp.status_code == 200:
models = resp.json().get("models", [])
model_names = [m.get("name", "") for m in models]
# Check if our model (or a prefix match) is available
self._available = any(self._model in name for name in model_names)
if not self._available:
LOG.warning(
"Ollama running but model '%s' not found. Available: %s. Pull with: ollama pull %s",
self._model,
model_names,
self._model,
)
return self._available
except Exception as exc:
LOG.warning("Ollama not reachable at %s: %s", self._base_url, exc)
self._available = False
return False
[docs]
async def generate_candidates(
self,
prompt: str,
n: int = 5,
temperature: float = 0.8,
) -> list[str]:
if not await self.is_available():
LOG.error(
"Ollama not available. Start with 'ollama serve' and ensure '%s' is pulled.",
self._model,
)
return []
candidates: list[str] = []
for i in range(n):
try:
resp = await self._client.post(
"/api/generate",
json={
"model": self._model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": 2000,
},
},
)
resp.raise_for_status()
data = resp.json()
text = data.get("response", "")
code = self._extract_code(text)
if code:
candidates.append(code)
except Exception as exc:
LOG.warning("Ollama generation %d/%d failed: %s", i + 1, n, exc)
LOG.info("Local LLM generated %d/%d candidates", len(candidates), n)
return candidates
@staticmethod
def _extract_code(text: str) -> str:
"""Extract Python code from Ollama response."""
# Try markdown fences first
pattern = r"```(?:python)?\s*\n(.*?)```"
matches = re.findall(pattern, text, re.DOTALL)
if matches:
return matches[0].strip()
return text.strip()
[docs]
async def close(self) -> None:
await self._client.aclose()