Skip to content

Commit cc6980c

Browse files
Merge pull request #157 from FurkanEdizkan/feat/local-models
feat(localai): local model status + llmfit deployability
2 parents ccbcbc6 + 7a4974a commit cc6980c

8 files changed

Lines changed: 847 additions & 0 deletions

File tree

engine/Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ WORKDIR /app
1212
COPY pyproject.toml uv.lock ./
1313
RUN uv sync --frozen --no-dev --no-install-project
1414

15+
# llmfit (optional) — scores which local LLMs fit the host hardware, shown on
16+
# Settings → Local models. Best-effort: the image builds fine without it.
17+
RUN uv tool install llmfit && ln -s /root/.local/bin/llmfit /usr/local/bin/llmfit \
18+
|| echo "llmfit install skipped"
19+
1520
# App source.
1621
COPY . .
1722

engine/ai/localfit.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""Local-model readiness — what's deployed (Ollama) and deployable (llmfit).
2+
3+
Two independent probes, combined into one cached snapshot:
4+
5+
- *Deployed*: the configured Ollama endpoint is asked for its version and
6+
installed models (`/api/version`, `/api/tags`).
7+
- *Deployable*: the optional `llmfit` CLI (https://github.com/AlexsJones/llmfit)
8+
scans the host hardware and scores which models would run well. When the
9+
binary is missing the snapshot simply says so — it is never auto-installed.
10+
11+
Both probes are best-effort and never raise; the Settings page renders
12+
whatever could be learned. Hardware scans are slow, so the snapshot is cached
13+
in-process for a few minutes.
14+
"""
15+
16+
import json
17+
import logging
18+
import subprocess
19+
import time
20+
from collections.abc import Callable
21+
from dataclasses import asdict, dataclass, field
22+
23+
import httpx
24+
from sqlmodel import Session
25+
26+
from appsettings.store import get_llm_credentials
27+
28+
log = logging.getLogger("capital.ai.localfit")
29+
30+
DEFAULT_OLLAMA_URL = "http://localhost:11434"
31+
LLMFIT_INSTALL_HINT = "uv tool install llmfit"
32+
33+
_CACHE_TTL_SECONDS = 300.0
34+
_LLMFIT_TIMEOUT_SECONDS = 60
35+
36+
#: Injectable JSON-over-HTTP getter — returns the parsed body. Tests fake it.
37+
JsonFetcher = Callable[[str], dict]
38+
39+
#: Injectable llmfit runner — returns the CLI's stdout. Tests fake it.
40+
LlmfitRunner = Callable[[list[str]], str]
41+
42+
43+
def _http_json(url: str) -> dict:
44+
resp = httpx.get(url, timeout=5.0)
45+
resp.raise_for_status()
46+
body = resp.json()
47+
return body if isinstance(body, dict) else {}
48+
49+
50+
def _run_llmfit(args: list[str]) -> str:
51+
"""Run the llmfit CLI. Raises FileNotFoundError when not installed."""
52+
result = subprocess.run( # noqa: S603 — fixed argv, no shell
53+
["llmfit", *args],
54+
capture_output=True,
55+
text=True,
56+
timeout=_LLMFIT_TIMEOUT_SECONDS,
57+
check=True,
58+
)
59+
return result.stdout
60+
61+
62+
@dataclass
63+
class OllamaModel:
64+
"""One model installed on the Ollama endpoint."""
65+
66+
name: str
67+
size_bytes: int = 0
68+
parameter_size: str = ""
69+
quantization: str = ""
70+
71+
72+
@dataclass
73+
class OllamaStatus:
74+
"""Whether local models are already deployed and which ones."""
75+
76+
reachable: bool = False
77+
base_url: str = DEFAULT_OLLAMA_URL
78+
version: str = ""
79+
models: list[OllamaModel] = field(default_factory=list)
80+
81+
82+
@dataclass
83+
class ModelFit:
84+
"""One llmfit row — a model the host hardware could run."""
85+
86+
model: str
87+
quantization: str = ""
88+
fit: str = "" # Perfect | Good | Marginal | Too Tight (llmfit's classes)
89+
est_speed: str = ""
90+
memory_gb: str = ""
91+
92+
93+
@dataclass
94+
class LlmfitStatus:
95+
"""Whether the hardware was scanned and what would fit."""
96+
97+
installed: bool = False
98+
install_hint: str = LLMFIT_INSTALL_HINT
99+
hardware: dict = field(default_factory=dict)
100+
fits: list[ModelFit] = field(default_factory=list)
101+
error: str = ""
102+
103+
104+
@dataclass
105+
class LocalAISnapshot:
106+
"""The combined local-model readiness picture."""
107+
108+
ollama: OllamaStatus = field(default_factory=OllamaStatus)
109+
llmfit: LlmfitStatus = field(default_factory=LlmfitStatus)
110+
checked_at: float = 0.0
111+
112+
def as_dict(self) -> dict:
113+
data = asdict(self)
114+
data.pop("checked_at")
115+
return data
116+
117+
118+
def _ollama_base_url(session: Session) -> str:
119+
configured = get_llm_credentials(session, "ollama")["base_url"].strip()
120+
if not configured:
121+
return DEFAULT_OLLAMA_URL
122+
# The chat adapter stores an OpenAI-compatible URL (…/v1); the native
123+
# status endpoints live at the server root.
124+
return configured.removesuffix("/").removesuffix("/v1").removesuffix("/")
125+
126+
127+
def probe_ollama(
128+
session: Session, *, fetch: JsonFetcher = _http_json
129+
) -> OllamaStatus:
130+
"""Ask the configured Ollama endpoint what is deployed."""
131+
base = _ollama_base_url(session)
132+
status = OllamaStatus(base_url=base)
133+
try:
134+
status.version = str(fetch(f"{base}/api/version").get("version", ""))
135+
status.reachable = True
136+
except Exception: # noqa: BLE001 — unreachable is an answer, not an error
137+
return status
138+
try:
139+
tags = fetch(f"{base}/api/tags")
140+
for item in tags.get("models", []) or []:
141+
details = item.get("details") or {}
142+
status.models.append(
143+
OllamaModel(
144+
name=str(item.get("name", "")),
145+
size_bytes=int(item.get("size", 0) or 0),
146+
parameter_size=str(details.get("parameter_size", "")),
147+
quantization=str(details.get("quantization_level", "")),
148+
)
149+
)
150+
except Exception: # noqa: BLE001 — version answered; tags are best-effort
151+
log.warning("ollama /api/tags failed", exc_info=True)
152+
return status
153+
154+
155+
def _parse_fits(payload: object) -> tuple[dict, list[ModelFit]]:
156+
"""Pull hardware + fit rows out of llmfit's JSON, tolerating shape drift."""
157+
if isinstance(payload, dict):
158+
hardware = payload.get("hardware") or payload.get("system") or {}
159+
rows = (
160+
payload.get("models")
161+
or payload.get("results")
162+
or payload.get("recommendations")
163+
or []
164+
)
165+
elif isinstance(payload, list): # some subcommands emit a bare array
166+
hardware, rows = {}, payload
167+
else:
168+
return {}, []
169+
fits: list[ModelFit] = []
170+
for row in rows:
171+
if not isinstance(row, dict):
172+
continue
173+
fits.append(
174+
ModelFit(
175+
model=str(row.get("model") or row.get("name") or ""),
176+
quantization=str(row.get("quantization") or row.get("quant") or ""),
177+
fit=str(row.get("fit") or row.get("fit_class") or row.get("rating") or ""),
178+
est_speed=str(
179+
row.get("est_speed")
180+
or row.get("speed")
181+
or row.get("tokens_per_second")
182+
or ""
183+
),
184+
memory_gb=str(row.get("memory_gb") or row.get("memory") or ""),
185+
)
186+
)
187+
return (hardware if isinstance(hardware, dict) else {}), fits
188+
189+
190+
def probe_llmfit(*, run: LlmfitRunner = _run_llmfit) -> LlmfitStatus:
191+
"""Scan the host with llmfit, if it is installed."""
192+
status = LlmfitStatus()
193+
for args in (["recommend", "--json"], ["fit", "--json"]):
194+
try:
195+
payload = json.loads(run(args))
196+
except FileNotFoundError:
197+
status.error = "llmfit is not installed"
198+
return status
199+
except Exception as exc: # noqa: BLE001 — try the fallback subcommand
200+
status.error = str(exc)[:200]
201+
continue
202+
status.installed = True
203+
status.error = ""
204+
status.hardware, status.fits = _parse_fits(payload)
205+
return status
206+
# Both subcommands ran but failed — the binary exists.
207+
status.installed = True
208+
return status
209+
210+
211+
_cached: LocalAISnapshot | None = None
212+
213+
214+
def snapshot(
215+
session: Session,
216+
*,
217+
force: bool = False,
218+
fetch: JsonFetcher = _http_json,
219+
run: LlmfitRunner = _run_llmfit,
220+
) -> LocalAISnapshot:
221+
"""The cached local-AI readiness snapshot (refreshed every ~5 minutes)."""
222+
global _cached
223+
now = time.monotonic()
224+
if not force and _cached is not None and now - _cached.checked_at < _CACHE_TTL_SECONDS:
225+
return _cached
226+
_cached = LocalAISnapshot(
227+
ollama=probe_ollama(session, fetch=fetch),
228+
llmfit=probe_llmfit(run=run),
229+
checked_at=now,
230+
)
231+
return _cached

engine/api/ai.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from fastapi import APIRouter, Depends, HTTPException, status
1212
from pydantic import BaseModel, Field
1313

14+
from ai import localfit
1415
from ai.analyze import analyze
1516
from ai.providers import LLMError, get_provider
1617
from ai.providers.base import Decision, LLMProvider
@@ -153,3 +154,56 @@ def dismiss_signal(signal_id: int, _: AdminUser, session: SessionDep) -> AISigna
153154
session.commit()
154155
session.refresh(signal)
155156
return signal
157+
158+
159+
class OllamaModelRead(BaseModel):
160+
name: str
161+
size_bytes: int
162+
parameter_size: str
163+
quantization: str
164+
165+
166+
class OllamaStatusRead(BaseModel):
167+
reachable: bool
168+
base_url: str
169+
version: str
170+
models: list[OllamaModelRead]
171+
172+
173+
class ModelFitRead(BaseModel):
174+
model: str
175+
quantization: str
176+
fit: str
177+
est_speed: str
178+
memory_gb: str
179+
180+
181+
class LlmfitStatusRead(BaseModel):
182+
installed: bool
183+
install_hint: str
184+
hardware: dict
185+
fits: list[ModelFitRead]
186+
error: str
187+
188+
189+
class LocalAIRead(BaseModel):
190+
"""Local-model readiness — deployed (Ollama) and deployable (llmfit)."""
191+
192+
ollama: OllamaStatusRead
193+
llmfit: LlmfitStatusRead
194+
195+
196+
@router.get("/local", response_model=LocalAIRead)
197+
def local_models(
198+
_: AdminUser, session: SessionDep, refresh: bool = False
199+
) -> LocalAIRead:
200+
"""Whether local models are deployed (Ollama) or deployable (llmfit).
201+
202+
The snapshot is cached for ~5 minutes; `refresh=true` re-probes now.
203+
"""
204+
snap = localfit.snapshot(session, force=refresh)
205+
data = snap.as_dict()
206+
return LocalAIRead(
207+
ollama=OllamaStatusRead(**data["ollama"]),
208+
llmfit=LlmfitStatusRead(**data["llmfit"]),
209+
)

0 commit comments

Comments
 (0)