llm.py defaulted to AI_PROVIDER=anthropic, AI_MODEL=qwen-light, and the dead .20 Jetson IP — none of which match how workflows/bot actually run it (ollama / coder / .103). Update the defaults and docstring to reflect the live self-hosted setup, and refresh the README note that flagged the mismatch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
"""Provider-agnostischer LLM-Client für die KI-CI.
|
|
|
|
Bewusst NUR stdlib (urllib) — so braucht der CI-Job kein `pip install` und
|
|
läuft in jedem schlanken Python-Image sofort.
|
|
|
|
Umschalten zwischen Backends über Env-Vars:
|
|
AI_PROVIDER=ollama -> <AI_BASE_URL>/chat/completions (Jetson, self-hosted, Default)
|
|
AI_PROVIDER=anthropic -> api.anthropic.com/v1/messages (Claude, optional)
|
|
|
|
Weitere Env-Vars:
|
|
AI_MODEL Modellname (Default je nach Provider; ollama: "coder" = qwen2.5-coder:7b)
|
|
AI_BASE_URL OpenAI-kompatible Basis-URL (für ollama),
|
|
Default http://192.168.178.103:11434/v1
|
|
JETSON_API_KEY Bearer-Token (für ollama, optional)
|
|
ANTHROPIC_API_KEY (für anthropic)
|
|
|
|
Provider-Wechsel ist damit ein reiner Env-Change in der Workflow-Datei
|
|
— KEINE Code-Änderung.
|
|
"""
|
|
import json
|
|
import os
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
|
|
def configured():
|
|
"""(ok, grund) — ob das gewählte Backend nutzbar konfiguriert ist.
|
|
|
|
Erlaubt den Workflows, den KI-Schritt sauber zu ÜBERSPRINGEN (exit 0) statt
|
|
zu failen, solange z.B. noch kein ANTHROPIC_API_KEY hinterlegt ist.
|
|
"""
|
|
provider = os.environ.get("AI_PROVIDER", "ollama").lower()
|
|
if provider == "anthropic":
|
|
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
return False, "ANTHROPIC_API_KEY ist nicht gesetzt"
|
|
return True, ""
|
|
if provider == "ollama":
|
|
return True, "" # Erreichbarkeit wird beim eigentlichen Call geprüft
|
|
return False, f"unbekannter AI_PROVIDER: {provider!r}"
|
|
|
|
|
|
def complete(system: str, user: str, *, max_tokens: int = 1500) -> str:
|
|
"""Schickt (system, user) ans konfigurierte Backend, gibt reinen Text zurück."""
|
|
provider = os.environ.get("AI_PROVIDER", "ollama").lower()
|
|
if provider == "anthropic":
|
|
model = os.environ.get("AI_MODEL", "claude-3-5-haiku-latest")
|
|
return _anthropic(system, user, model, max_tokens)
|
|
if provider == "ollama":
|
|
model = os.environ.get("AI_MODEL", "coder")
|
|
return _ollama_openai(system, user, model, max_tokens)
|
|
raise SystemExit(f"[llm] Unbekannter AI_PROVIDER: {provider!r}")
|
|
|
|
|
|
def _post(url: str, headers: dict, payload: dict) -> dict:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=180) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read().decode("utf-8", "replace")
|
|
raise SystemExit(f"[llm] HTTP {exc.code} von {url}: {body[:600]}")
|
|
except urllib.error.URLError as exc:
|
|
raise SystemExit(f"[llm] Verbindung zu {url} fehlgeschlagen: {exc.reason}")
|
|
|
|
|
|
def _anthropic(system: str, user: str, model: str, max_tokens: int) -> str:
|
|
try:
|
|
key = os.environ["ANTHROPIC_API_KEY"]
|
|
except KeyError:
|
|
raise SystemExit("[llm] ANTHROPIC_API_KEY ist nicht gesetzt.")
|
|
resp = _post(
|
|
"https://api.anthropic.com/v1/messages",
|
|
{
|
|
"x-api-key": key,
|
|
"anthropic-version": "2023-06-01",
|
|
"content-type": "application/json",
|
|
},
|
|
{
|
|
"model": model,
|
|
"max_tokens": max_tokens,
|
|
"system": system,
|
|
"messages": [{"role": "user", "content": user}],
|
|
},
|
|
)
|
|
parts = [b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"]
|
|
return "".join(parts).strip()
|
|
|
|
|
|
def _ollama_openai(system: str, user: str, model: str, max_tokens: int) -> str:
|
|
base = os.environ.get("AI_BASE_URL", "http://192.168.178.103:11434/v1").rstrip("/")
|
|
headers = {"content-type": "application/json"}
|
|
key = os.environ.get("JETSON_API_KEY", "")
|
|
if key:
|
|
headers["Authorization"] = f"Bearer {key}"
|
|
resp = _post(
|
|
f"{base}/chat/completions",
|
|
headers,
|
|
{
|
|
"model": model,
|
|
"max_tokens": max_tokens,
|
|
"messages": [
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
],
|
|
},
|
|
)
|
|
return resp["choices"][0]["message"]["content"].strip()
|