222 lines
7.3 KiB
Python
222 lines
7.3 KiB
Python
"""OpenRouter LLM-Wrapper mit Tool-Calling.
|
|
|
|
Das LLM entscheidet selbst welche Datenquellen es abfragt.
|
|
Neue Datenquelle = Tool-Definition hier + Handler in context.py.
|
|
"""
|
|
|
|
import json
|
|
import requests
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from core import config
|
|
|
|
MODEL = "openai/gpt-4o-mini"
|
|
MAX_TOOL_ROUNDS = 3
|
|
|
|
SYSTEM_PROMPT = """Du bist der Hausmeister-Bot für ein Homelab mit mehreren Proxmox-Servern.
|
|
Du antwortest kurz, präzise und auf Deutsch.
|
|
Du hast Tools um Live-Daten abzufragen. Nutze sie um Fragen zu beantworten.
|
|
Wenn alles in Ordnung ist, sag das kurz. Bei Problemen erkläre was los ist und schlage Lösungen vor.
|
|
Nutze Emojis sparsam. Formatiere für Telegram (kein Markdown, nur einfacher Text)."""
|
|
|
|
TOOLS = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_all_containers",
|
|
"description": "Status aller Container auf allen Proxmox-Servern (running/stopped, RAM, Uptime)",
|
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_container_detail",
|
|
"description": "Detail-Status eines einzelnen Containers. Suche per VMID (z.B. 101) oder Name (z.B. wordpress, rss-manager, forgejo)",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "VMID (z.B. '109') oder Container-Name (z.B. 'wordpress')"}
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_errors",
|
|
"description": "Aktuelle Fehler-Logs aus Loki (alle Container)",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"hours": {"type": "number", "description": "Zeitraum in Stunden (default: 2)", "default": 2}
|
|
},
|
|
"required": [],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_container_logs",
|
|
"description": "Letzte Logs eines bestimmten Containers aus Loki",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"container": {"type": "string", "description": "Hostname des Containers (z.B. 'rss-manager', 'wordpress-v2')"},
|
|
"hours": {"type": "number", "description": "Zeitraum in Stunden (default: 1)", "default": 1},
|
|
},
|
|
"required": ["container"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_silent_hosts",
|
|
"description": "Welche Hosts senden keine Logs mehr? (Stille-Check)",
|
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_server_metrics",
|
|
"description": "CPU, RAM, Disk, Load, Uptime von Proxmox-Servern via Prometheus. Ohne host = alle Server.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"host": {
|
|
"type": "string",
|
|
"description": "Hostname (pve-hetzner, pve-ka-1, pve-ka-2, pve-ka-3, pve-mu-2, pve-mu-3, pve-he, pbs-mu). Leer = alle.",
|
|
}
|
|
},
|
|
"required": [],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_server_warnings",
|
|
"description": "Nur Warnungen: Server mit CPU>80%, RAM>85% oder Disk>85%",
|
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_wordpress_stats",
|
|
"description": "WordPress/Blog-Statistiken: Posts heute/gestern/Woche, offene Kommentare, letzte Artikel, Plugin-Status",
|
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_feed_stats",
|
|
"description": "RSS-Feed-Status: Aktive Feeds, Artikel heute/gestern, Fehler",
|
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _get_api_key() -> str:
|
|
cfg = config.parse_config()
|
|
return cfg.api_keys.get("openrouter_key", "")
|
|
|
|
|
|
def _call_openrouter(messages: list, api_key: str, use_tools: bool = True) -> dict:
|
|
payload = {
|
|
"model": MODEL,
|
|
"messages": messages,
|
|
"max_tokens": 600,
|
|
}
|
|
if use_tools:
|
|
payload["tools"] = TOOLS
|
|
payload["tool_choice"] = "auto"
|
|
|
|
r = requests.post(
|
|
"https://openrouter.ai/api/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
json=payload,
|
|
timeout=30,
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def ask(question: str, context: str) -> str:
|
|
"""Legacy-Funktion fuer /commands die bereits Kontext mitbringen."""
|
|
api_key = _get_api_key()
|
|
if not api_key:
|
|
return "OpenRouter API Key fehlt in homelab.conf"
|
|
|
|
messages = [
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"},
|
|
]
|
|
try:
|
|
data = _call_openrouter(messages, api_key, use_tools=False)
|
|
return data["choices"][0]["message"]["content"]
|
|
except Exception as e:
|
|
return f"LLM-Fehler: {e}"
|
|
|
|
|
|
def ask_with_tools(question: str, tool_handlers: dict) -> str:
|
|
"""Freitext-Frage mit automatischem Tool-Calling.
|
|
|
|
tool_handlers: dict von tool_name -> callable(**kwargs) -> str
|
|
"""
|
|
api_key = _get_api_key()
|
|
if not api_key:
|
|
return "OpenRouter API Key fehlt in homelab.conf"
|
|
|
|
messages = [
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": question},
|
|
]
|
|
|
|
try:
|
|
for _round in range(MAX_TOOL_ROUNDS):
|
|
data = _call_openrouter(messages, api_key, use_tools=True)
|
|
choice = data["choices"][0]
|
|
msg = choice["message"]
|
|
|
|
tool_calls = msg.get("tool_calls")
|
|
if not tool_calls:
|
|
return msg.get("content", "Keine Antwort vom LLM.")
|
|
|
|
messages.append(msg)
|
|
|
|
for tc in tool_calls:
|
|
fn_name = tc["function"]["name"]
|
|
try:
|
|
fn_args = json.loads(tc["function"]["arguments"])
|
|
except (json.JSONDecodeError, KeyError):
|
|
fn_args = {}
|
|
|
|
handler = tool_handlers.get(fn_name)
|
|
if handler:
|
|
try:
|
|
result = handler(**fn_args)
|
|
except Exception as e:
|
|
result = f"Fehler bei {fn_name}: {e}"
|
|
else:
|
|
result = f"Unbekanntes Tool: {fn_name}"
|
|
|
|
messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": tc["id"],
|
|
"content": str(result)[:3000],
|
|
})
|
|
|
|
data = _call_openrouter(messages, api_key, use_tools=False)
|
|
return data["choices"][0]["message"]["content"]
|
|
|
|
except Exception as e:
|
|
return f"LLM-Fehler: {e}"
|