278 lines
9.4 KiB
Python
278 lines
9.4 KiB
Python
|
|
"""Unified LLM router — Ollama, DeepSeek, Gemini, Groq, OpenRouter, custom OpenAI-compatible."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from app.config import settings
|
||
|
|
from app.db import execute, fetch_all, fetch_one
|
||
|
|
from app.services import ollama
|
||
|
|
|
||
|
|
# Preset catalog for Settings UI (signup links + default models)
|
||
|
|
LLM_PRESETS: dict[str, dict[str, Any]] = {
|
||
|
|
"ollama": {
|
||
|
|
"label": "Ollama (lokaal)",
|
||
|
|
"api_base_url": "",
|
||
|
|
"models": ["qwen3:8b", "gemma3:12b", "llama3.2", "mistral"],
|
||
|
|
"needs_key": False,
|
||
|
|
"hint": "Geen API key — draait op je Ollama server.",
|
||
|
|
},
|
||
|
|
"deepseek": {
|
||
|
|
"label": "DeepSeek",
|
||
|
|
"api_base_url": "https://api.deepseek.com/v1",
|
||
|
|
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||
|
|
"needs_key": True,
|
||
|
|
"signup_url": "https://platform.deepseek.com/",
|
||
|
|
"hint": "Goedkoop · sterk voor code en analyse.",
|
||
|
|
},
|
||
|
|
"gemini": {
|
||
|
|
"label": "Google Gemini",
|
||
|
|
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||
|
|
"models": ["gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro"],
|
||
|
|
"needs_key": True,
|
||
|
|
"signup_url": "https://aistudio.google.com/apikey",
|
||
|
|
"hint": "Gratis tier via Google AI Studio.",
|
||
|
|
},
|
||
|
|
"groq": {
|
||
|
|
"label": "Groq (snel · gratis tier)",
|
||
|
|
"api_base_url": "https://api.groq.com/openai/v1",
|
||
|
|
"models": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"],
|
||
|
|
"needs_key": True,
|
||
|
|
"signup_url": "https://console.groq.com/",
|
||
|
|
"hint": "Zeer snelle inference · gratis limiet.",
|
||
|
|
},
|
||
|
|
"openrouter": {
|
||
|
|
"label": "OpenRouter",
|
||
|
|
"api_base_url": "https://openrouter.ai/api/v1",
|
||
|
|
"models": [
|
||
|
|
"google/gemini-2.0-flash-exp:free",
|
||
|
|
"deepseek/deepseek-r1:free",
|
||
|
|
"meta-llama/llama-3.3-70b-instruct:free",
|
||
|
|
],
|
||
|
|
"needs_key": True,
|
||
|
|
"signup_url": "https://openrouter.ai/",
|
||
|
|
"hint": "Veel gratis modellen via één API.",
|
||
|
|
},
|
||
|
|
"mistral": {
|
||
|
|
"label": "Mistral AI",
|
||
|
|
"api_base_url": "https://api.mistral.ai/v1",
|
||
|
|
"models": ["mistral-small-latest", "open-mistral-nemo"],
|
||
|
|
"needs_key": True,
|
||
|
|
"signup_url": "https://console.mistral.ai/",
|
||
|
|
"hint": "EU-hosted · gratis proef tier.",
|
||
|
|
},
|
||
|
|
"custom_openai": {
|
||
|
|
"label": "Custom OpenAI-compatible",
|
||
|
|
"api_base_url": "",
|
||
|
|
"models": [],
|
||
|
|
"needs_key": True,
|
||
|
|
"hint": "Elke API die /v1/chat/completions ondersteunt.",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def list_presets() -> list[dict[str, Any]]:
|
||
|
|
out = []
|
||
|
|
for key, meta in LLM_PRESETS.items():
|
||
|
|
row = dict(meta)
|
||
|
|
row["id"] = key
|
||
|
|
out.append(row)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _mask_provider(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||
|
|
if not row:
|
||
|
|
return None
|
||
|
|
out = dict(row)
|
||
|
|
for k, v in list(out.items()):
|
||
|
|
if hasattr(v, "isoformat"):
|
||
|
|
out[k] = v.isoformat()
|
||
|
|
if isinstance(out.get("extra_config"), str):
|
||
|
|
try:
|
||
|
|
out["extra_config"] = json.loads(out["extra_config"])
|
||
|
|
except Exception:
|
||
|
|
out["extra_config"] = {}
|
||
|
|
out["api_key_set"] = bool(row.get("api_key"))
|
||
|
|
out.pop("api_key", None)
|
||
|
|
preset = LLM_PRESETS.get(out.get("provider_type") or "", {})
|
||
|
|
out["preset_label"] = preset.get("label", out.get("provider_type"))
|
||
|
|
out["needs_key"] = preset.get("needs_key", True)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def list_providers() -> list[dict[str, Any]]:
|
||
|
|
rows = fetch_all("SELECT * FROM llm_providers ORDER BY is_default DESC, is_active DESC, id ASC")
|
||
|
|
return [_mask_provider(r) for r in rows if r]
|
||
|
|
|
||
|
|
|
||
|
|
def get_provider(provider_id: int | None = None) -> dict[str, Any] | None:
|
||
|
|
if provider_id:
|
||
|
|
return fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
|
||
|
|
row = fetch_one(
|
||
|
|
"SELECT * FROM llm_providers WHERE is_default = TRUE ORDER BY id LIMIT 1"
|
||
|
|
)
|
||
|
|
if row:
|
||
|
|
return row
|
||
|
|
row = fetch_one(
|
||
|
|
"SELECT * FROM llm_providers WHERE is_active = TRUE ORDER BY id LIMIT 1"
|
||
|
|
)
|
||
|
|
if row:
|
||
|
|
return row
|
||
|
|
return fetch_one("SELECT * FROM llm_providers ORDER BY id LIMIT 1")
|
||
|
|
|
||
|
|
|
||
|
|
def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
|
||
|
|
row = get_provider(provider_id)
|
||
|
|
if not row:
|
||
|
|
return {
|
||
|
|
"id": 0,
|
||
|
|
"label": "Ollama lokaal",
|
||
|
|
"provider_type": "ollama",
|
||
|
|
"api_base_url": settings.OLLAMA_URL,
|
||
|
|
"api_key": "",
|
||
|
|
"model": settings.OLLAMA_MODEL,
|
||
|
|
"extra_config": {},
|
||
|
|
}
|
||
|
|
return row
|
||
|
|
|
||
|
|
|
||
|
|
async def chat_messages(
|
||
|
|
messages: list[dict[str, str]],
|
||
|
|
*,
|
||
|
|
provider_id: int | None = None,
|
||
|
|
model: str | None = None,
|
||
|
|
timeout: float = 120.0,
|
||
|
|
) -> tuple[str, dict[str, Any]]:
|
||
|
|
"""Returns (reply_text, meta dict with provider info)."""
|
||
|
|
prov = resolve_provider(provider_id)
|
||
|
|
ptype = (prov.get("provider_type") or "ollama").lower()
|
||
|
|
use_model = model or prov.get("model") or settings.OLLAMA_MODEL
|
||
|
|
ollama_timeout = min(timeout, 85.0) if ptype == "ollama" else timeout
|
||
|
|
|
||
|
|
if ptype == "ollama":
|
||
|
|
try:
|
||
|
|
reply = await ollama.chat_messages(messages, timeout=ollama_timeout, model=use_model)
|
||
|
|
except Exception as exc:
|
||
|
|
raise RuntimeError(
|
||
|
|
f"Ollama timeout/ offline ({exc}). "
|
||
|
|
"Voeg DeepSeek of Gemini toe via Instellingen → AI / LLM voor snelle cloud-chat."
|
||
|
|
) from exc
|
||
|
|
return reply, {
|
||
|
|
"provider_id": prov.get("id"),
|
||
|
|
"provider_type": "ollama",
|
||
|
|
"provider_label": prov.get("label") or "Ollama",
|
||
|
|
"model": use_model,
|
||
|
|
}
|
||
|
|
|
||
|
|
api_key = (prov.get("api_key") or "").strip()
|
||
|
|
if not api_key:
|
||
|
|
raise RuntimeError(
|
||
|
|
f"Geen API key voor {prov.get('label') or ptype} — voeg key toe in Instellingen → AI / LLM"
|
||
|
|
)
|
||
|
|
|
||
|
|
base = (prov.get("api_base_url") or "").strip().rstrip("/")
|
||
|
|
if not base:
|
||
|
|
preset = LLM_PRESETS.get(ptype, {})
|
||
|
|
base = (preset.get("api_base_url") or "").rstrip("/")
|
||
|
|
if not base:
|
||
|
|
raise RuntimeError(f"Geen API URL voor provider {prov.get('label')}")
|
||
|
|
|
||
|
|
extra = prov.get("extra_config") or {}
|
||
|
|
if isinstance(extra, str):
|
||
|
|
try:
|
||
|
|
extra = json.loads(extra)
|
||
|
|
except Exception:
|
||
|
|
extra = {}
|
||
|
|
|
||
|
|
url = f"{base}/chat/completions"
|
||
|
|
headers = {
|
||
|
|
"Authorization": f"Bearer {api_key}",
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
}
|
||
|
|
if ptype == "openrouter":
|
||
|
|
headers["HTTP-Referer"] = extra.get("referer", "https://foodlinkk.local")
|
||
|
|
headers["X-Title"] = extra.get("title", "Foodlinkk Command Center")
|
||
|
|
|
||
|
|
payload: dict[str, Any] = {
|
||
|
|
"model": use_model,
|
||
|
|
"messages": messages,
|
||
|
|
"temperature": float(extra.get("temperature", 0.4)),
|
||
|
|
"max_tokens": int(extra.get("max_tokens", 2048)),
|
||
|
|
}
|
||
|
|
|
||
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||
|
|
resp = await client.post(url, headers=headers, json=payload)
|
||
|
|
if resp.status_code >= 400:
|
||
|
|
detail = resp.text[:500]
|
||
|
|
try:
|
||
|
|
detail = resp.json().get("error", {}).get("message", detail)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
raise RuntimeError(f"{prov.get('label')}: {detail}")
|
||
|
|
data = resp.json()
|
||
|
|
choices = data.get("choices") or []
|
||
|
|
if not choices:
|
||
|
|
raise RuntimeError(f"{prov.get('label')}: leeg antwoord")
|
||
|
|
content = (choices[0].get("message") or {}).get("content") or ""
|
||
|
|
return content.strip(), {
|
||
|
|
"provider_id": prov.get("id"),
|
||
|
|
"provider_type": ptype,
|
||
|
|
"provider_label": prov.get("label"),
|
||
|
|
"model": use_model,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def generate(
|
||
|
|
prompt: str,
|
||
|
|
system: str | None = None,
|
||
|
|
*,
|
||
|
|
provider_id: int | None = None,
|
||
|
|
model: str | None = None,
|
||
|
|
timeout: float = 120.0,
|
||
|
|
) -> str:
|
||
|
|
messages: list[dict[str, str]] = []
|
||
|
|
if system:
|
||
|
|
messages.append({"role": "system", "content": system})
|
||
|
|
messages.append({"role": "user", "content": prompt})
|
||
|
|
reply, _meta = await chat_messages(
|
||
|
|
messages, provider_id=provider_id, model=model, timeout=timeout
|
||
|
|
)
|
||
|
|
return reply
|
||
|
|
|
||
|
|
|
||
|
|
async def test_provider(provider_id: int) -> tuple[bool, str]:
|
||
|
|
prov = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
|
||
|
|
if not prov:
|
||
|
|
return False, "Provider niet gevonden"
|
||
|
|
try:
|
||
|
|
reply, meta = await chat_messages(
|
||
|
|
[{"role": "user", "content": "Antwoord met exact één woord: OK"}],
|
||
|
|
provider_id=provider_id,
|
||
|
|
timeout=60.0,
|
||
|
|
)
|
||
|
|
msg = f"{meta.get('provider_label')} · {meta.get('model')} — {reply[:80]}"
|
||
|
|
execute(
|
||
|
|
"""UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
|
||
|
|
last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
|
||
|
|
("ok", msg, provider_id),
|
||
|
|
)
|
||
|
|
return True, msg
|
||
|
|
except Exception as exc:
|
||
|
|
execute(
|
||
|
|
"""UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
|
||
|
|
last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
|
||
|
|
("error", str(exc)[:500], provider_id),
|
||
|
|
)
|
||
|
|
return False, str(exc)
|
||
|
|
|
||
|
|
|
||
|
|
def set_default(provider_id: int) -> None:
|
||
|
|
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
|
||
|
|
execute(
|
||
|
|
"UPDATE llm_providers SET is_default = TRUE, is_active = TRUE, updated_at = NOW() WHERE id = %s",
|
||
|
|
(provider_id,),
|
||
|
|
)
|