"""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=anthropic -> api.anthropic.com/v1/messages (Claude, jetzt) AI_PROVIDER=ollama -> /chat/completions (Jetson, später) Weitere Env-Vars: AI_MODEL Modellname (Default je nach Provider) ANTHROPIC_API_KEY (für anthropic) AI_BASE_URL OpenAI-kompatible Basis-URL (für ollama), Default http://192.168.178.20:11434/v1 JETSON_API_KEY Bearer-Token (für ollama, optional) Der Wechsel auf den Jetson 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", "anthropic").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", "anthropic").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", "qwen-light") 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.20: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()