182 lines
6.2 KiB
Python
182 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
🐠 Clownfischserver – OpenRouter Free Models Fallback
|
||
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||
License: GPL-3.0 – Keep it open. Always.
|
||
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import asyncio
|
||
import requests
|
||
from typing import Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||
|
||
# Free models on OpenRouter (no credit card needed)
|
||
# openrouter/free auto-selects the best available free model
|
||
FREE_MODELS = {
|
||
"coder": "openrouter/free",
|
||
"chat": "openrouter/free",
|
||
}
|
||
|
||
|
||
def _extract_json(raw: str) -> dict:
|
||
"""Extract JSON from response."""
|
||
text = raw.strip()
|
||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||
text = re.sub(r"\s*```$", "", text)
|
||
text = text.strip()
|
||
|
||
try:
|
||
return json.loads(text)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
|
||
if match:
|
||
try:
|
||
return json.loads(match.group())
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
raise json.JSONDecodeError("No valid JSON found", text, 0)
|
||
|
||
|
||
class OpenRouterClient:
|
||
"""OpenRouter Free Models Client – fallback when Ollama times out."""
|
||
|
||
def __init__(self):
|
||
self.api_key = OPENROUTER_API_KEY
|
||
self.base_url = OPENROUTER_BASE_URL
|
||
if self.api_key:
|
||
logger.info("OpenRouter Fallback konfiguriert")
|
||
else:
|
||
logger.info("OpenRouter nicht konfiguriert – Fallback deaktiviert")
|
||
|
||
def is_available(self) -> bool:
|
||
return bool(self.api_key)
|
||
|
||
def _chat_sync(self, messages: list, system: Optional[str] = None, model: str = None) -> str:
|
||
"""Synchronous OpenRouter API call."""
|
||
if not self.api_key:
|
||
raise RuntimeError("OpenRouter API Key nicht konfiguriert")
|
||
|
||
model = model or FREE_MODELS["chat"]
|
||
|
||
api_messages = []
|
||
if system:
|
||
api_messages.append({"role": "system", "content": system})
|
||
api_messages.extend(messages)
|
||
|
||
try:
|
||
response = requests.post(
|
||
f"{self.base_url}/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"model": model,
|
||
"messages": api_messages,
|
||
"temperature": 0.7,
|
||
},
|
||
timeout=60,
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||
except requests.exceptions.ConnectionError:
|
||
raise RuntimeError("OpenRouter nicht erreichbar")
|
||
except requests.exceptions.Timeout:
|
||
raise RuntimeError("OpenRouter Timeout")
|
||
except Exception as e:
|
||
raise RuntimeError(f"OpenRouter Fehler: {e}")
|
||
|
||
async def generate_shell_command(self, description: str) -> dict:
|
||
"""Generate shell command via OpenRouter."""
|
||
loop = asyncio.get_running_loop()
|
||
|
||
system = "Server: Ubuntu 24.04, Clownfischserver setup."
|
||
|
||
prompt = (
|
||
"You are a Linux bash command generator for Ubuntu 24.04.\n"
|
||
"Convert the request into a single non-interactive bash command.\n"
|
||
"Respond ONLY with JSON: {\"dangerous\": bool, \"reason\": string, \"command\": string}\n"
|
||
"No interactive commands (nano, vim, crontab -e). Use sudo when needed.\n\n"
|
||
f"Request: {description}\n\nJSON only:"
|
||
)
|
||
|
||
try:
|
||
raw = await loop.run_in_executor(
|
||
None,
|
||
lambda: self._chat_sync(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
system=system,
|
||
model=FREE_MODELS["coder"],
|
||
),
|
||
)
|
||
result = _extract_json(raw)
|
||
if "command" not in result:
|
||
result["command"] = ""
|
||
if "dangerous" not in result:
|
||
result["dangerous"] = False
|
||
logger.info(f"OpenRouter shell: {result}")
|
||
return result
|
||
except json.JSONDecodeError as e:
|
||
logger.warning(f"OpenRouter JSON error: {e}")
|
||
return {"dangerous": False, "reason": "", "command": ""}
|
||
except Exception as e:
|
||
logger.error(f"OpenRouter shell failed: {e}")
|
||
raise
|
||
|
||
async def chat(self, message: str, history: list = None) -> str:
|
||
"""General chat via OpenRouter."""
|
||
loop = asyncio.get_running_loop()
|
||
messages = list(history) if history else []
|
||
messages.append({"role": "user", "content": message})
|
||
|
||
system = (
|
||
"Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n"
|
||
"Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt."
|
||
)
|
||
|
||
try:
|
||
return await loop.run_in_executor(
|
||
None,
|
||
lambda: self._chat_sync(
|
||
messages=messages,
|
||
system=system,
|
||
model=FREE_MODELS["chat"],
|
||
),
|
||
)
|
||
except Exception as e:
|
||
return f"OpenRouter Fehler: {e}"
|
||
|
||
async def explain_output(self, command: str, output: str, error: str = "") -> str:
|
||
"""Explain command output via OpenRouter."""
|
||
loop = asyncio.get_running_loop()
|
||
|
||
content = f"Command: {command}\nOutput:\n{output}"
|
||
if error:
|
||
content += f"\nErrors:\n{error}"
|
||
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
|
||
|
||
try:
|
||
return await loop.run_in_executor(
|
||
None,
|
||
lambda: self._chat_sync(
|
||
messages=[{"role": "user", "content": content}],
|
||
model=FREE_MODELS["chat"],
|
||
),
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"OpenRouter explain failed: {e}")
|
||
return ""
|