ci-ai: KI-Schicht für die Gitea-CI (PR-Review, Security-Erklärung, CI-Failure-Analyse)

Provider-agnostische LLM-Anbindung (Anthropic jetzt, Jetson/Ollama später per
Env-Var umschaltbar). Stdlib-only Skripte + Beispiel-Workflows. GitLab-Duo-Parität
für self-hosted Gitea + act_runner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 15:38:53 +02:00
commit daf89ff8b9
10 changed files with 592 additions and 0 deletions

92
scripts/llm.py Normal file
View File

@@ -0,0 +1,92 @@
"""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 -> <AI_BASE_URL>/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 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()