feat: initial commit

This commit is contained in:
2026-08-29 13:17:59 +02:00
commit 142f5f5759
91 changed files with 6155 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Odczyt llm/models.yml: wykrycie backendu i renderowanie zmiennych środowiskowych.
Jedno miejsce decyduje, jaki backend jest właściwy dla platformy i jakie zmienne
dostaje silnik - Taskfile tylko o to pyta, nie duplikuje wiedzy.
Użycie:
backend.py detect # nazwa backendu dla tej maszyny
backend.py env [--backend NAME] # linie `export ...` do eval
backend.py get serve.model [--backend NAME]
backend.py list
"""
from __future__ import annotations
import argparse
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
import yaml
MODELS_FILE = Path(__file__).resolve().parent.parent / "models.yml"
def load() -> dict[str, Any]:
return yaml.safe_load(MODELS_FILE.read_text(encoding="utf-8")) or {}
def _has_nvidia_gpu() -> bool:
if not shutil.which("nvidia-smi"):
return False
try:
return subprocess.run(["nvidia-smi", "-L"], capture_output=True, timeout=10).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def _has_docker() -> bool:
if not shutil.which("docker"):
return False
try:
return subprocess.run(["docker", "info"], capture_output=True, timeout=20).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def detect() -> str:
"""Kolejność jest świadoma: najpierw akceleracja sprzętowa, potem cokolwiek, co działa."""
system = platform.system().lower()
if system == "darwin" and platform.machine() == "arm64":
return "vllm-metal"
if system == "linux" and _has_nvidia_gpu() and _has_docker():
return "vllm-gpu"
if shutil.which("ollama") or _has_docker():
return "ollama"
# Brak GPU, Metala i Ollamy: realnego modelu i tak nie ma sensu tu uruchamiać.
return "mock"
def resolve(name: str | None) -> tuple[str, dict[str, Any]]:
data = load()
backends = data.get("backends") or {}
if not name or name == "auto":
name = detect()
if name not in backends:
raise SystemExit(f"Nieznany backend '{name}'. Dostępne: {', '.join(sorted(backends))}")
return name, backends[name]
def _dotted(config: dict[str, Any], path: str) -> Any:
value: Any = config
for part in path.split("."):
if not isinstance(value, dict) or part not in value:
raise SystemExit(f"Brak klucza '{path}' w konfiguracji backendu")
value = value[part]
return value
def cmd_env(args: argparse.Namespace) -> None:
name, config = resolve(args.backend)
models = config.get("models") or {}
lines = [
f"export CODEMOD_LLM_BACKEND={name}",
f"export CODEMOD_MODEL_PROVIDER={config.get('provider', 'openai_like')}",
f"export CODEMOD_LLM_BASE_URL={config['base_url']}",
f"export CODEMOD_LLM_API_KEY={config.get('api_key', 'not-required')}",
]
lines += [f"export CODEMOD_MODEL_{role.upper()}={model}" for role, model in sorted(models.items())]
print("\n".join(lines))
def cmd_detect(args: argparse.Namespace) -> None:
requested = (args.requested or "auto").strip()
print(detect() if requested in ("", "auto") else requested)
def cmd_get(args: argparse.Namespace) -> None:
_, config = resolve(args.backend)
value = _dotted(config, args.key)
print(" ".join(str(v) for v in value) if isinstance(value, list) else value)
def cmd_list(_: argparse.Namespace) -> None:
data = load()
current = detect()
for name, config in (data.get("backends") or {}).items():
marker = "*" if name == current else " "
platforms = ", ".join(config.get("platforms", []))
print(f" {marker} {name:<12} {config['base_url']:<32} [{platforms}]")
print("\n * = wykryty jako właściwy dla tej maszyny")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
det = sub.add_parser("detect")
det.add_argument("--requested", default="auto", help="jawnie wskazany backend; 'auto' = wykrycie")
det.set_defaults(func=cmd_detect)
sub.add_parser("list").set_defaults(func=cmd_list)
env = sub.add_parser("env")
env.add_argument("--backend")
env.set_defaults(func=cmd_env)
get = sub.add_parser("get")
get.add_argument("key")
get.add_argument("--backend")
get.set_defaults(func=cmd_get)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
sys.exit(main())
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Uruchamia komendę w tle, odczepioną od bieżącej sesji terminala.
Powód istnienia: `nohup ... &` wewnątrz zadania go-task nie wystarcza - interpreter
zadania kończy się razem z krokiem i zabija potomka. `start_new_session=True` robi
to, co `setsid` na Linuksie, a działa też na macOS, gdzie `setsid` nie istnieje.
Użycie:
daemonize.py --pidfile .runs/llm/mock.pid --log .runs/llm/mock.log -- python3 llm/mock_server.py
"""
from __future__ import annotations
import argparse
import os
import signal
import subprocess
import sys
from pathlib import Path
def already_running(pidfile: Path) -> int | None:
if not pidfile.exists():
return None
try:
pid = int(pidfile.read_text().strip())
os.kill(pid, 0)
except (ValueError, ProcessLookupError, PermissionError, OSError):
return None
return pid
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--pidfile", required=True)
parser.add_argument("--log", required=True)
parser.add_argument("--stop", action="store_true", help="zatrzymaj proces z pidfile zamiast startować")
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
pidfile = Path(args.pidfile)
pidfile.parent.mkdir(parents=True, exist_ok=True)
if args.stop:
pid = already_running(pidfile)
if pid is None:
print(f"nic nie działa pod {pidfile}")
pidfile.unlink(missing_ok=True)
return 0
os.kill(pid, signal.SIGTERM)
pidfile.unlink(missing_ok=True)
print(f"zatrzymano pid {pid}")
return 0
command = [arg for arg in args.command if arg != "--"]
if not command:
parser.error("podaj komendę do uruchomienia po --")
running = already_running(pidfile)
if running is not None:
print(f"już działa (pid {running})")
return 0
log = Path(args.log)
log.parent.mkdir(parents=True, exist_ok=True)
with log.open("ab") as handle:
process = subprocess.Popen(
command,
stdout=handle,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
start_new_session=True, # odpowiednik setsid, przenośny między Linuksem a macOS
)
pidfile.write_text(str(process.pid))
print(f"uruchomiono pid {process.pid} (log: {log})")
return 0
if __name__ == "__main__":
sys.exit(main())
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Podgląd logów backendu LLM - jedno wejście niezależnie od tego, czy backend
# działa w kontenerze (vllm-gpu) czy jako proces z plikiem logu (vllm-metal, ollama, mock).
#
# Użycie:
# logs.sh --backend vllm-gpu [--tail 100] [--follow|--no-follow] [--grep WZORZEC] [--save PLIK]
#
# Domyślnie śledzi na bieżąco (jak `tail -f`). --no-follow robi jednorazowy zrzut,
# co jest tym, czego się chce przy wklejaniu fragmentu do zgłoszenia.
set -euo pipefail
# Ctrl-C przy śledzeniu logów to normalne zakończenie, nie awaria - bez tego
# go-task raportowałby "Failed to run task" za każdym razem, gdy ktoś przerwie podgląd.
trap 'exit 0' INT TERM
BACKEND=""
RUN_DIR=".runs/llm"
TAIL="100"
FOLLOW="true"
PATTERN=""
SAVE=""
COMPOSE_FILE="llm/docker-compose.yml"
usage() { sed -n '2,12p' "$0" >&2; exit "${1:-1}"; }
while [ $# -gt 0 ]; do
case "$1" in
--backend) BACKEND="${2:?}"; shift 2 ;;
--run-dir) RUN_DIR="${2:?}"; shift 2 ;;
--tail) TAIL="${2:?}"; shift 2 ;;
--grep) PATTERN="${2:?}"; shift 2 ;;
--save) SAVE="${2:?}"; shift 2 ;;
--follow) FOLLOW="true"; shift ;;
--no-follow) FOLLOW="false"; shift ;;
-h|--help) usage 0 ;;
*) echo "nieznany argument: $1" >&2; usage ;;
esac
done
[ -n "$BACKEND" ] || { echo "podaj --backend" >&2; exit 1; }
# Zapis do pliku bez sensu w trybie śledzenia - zrzut ma się skończyć.
[ -n "$SAVE" ] && FOLLOW="false"
log_file_for() {
case "$1" in
vllm-metal) echo "$RUN_DIR/vllm-metal.log" ;;
ollama) echo "$RUN_DIR/ollama.log" ;;
mock) echo "$RUN_DIR/mock.log" ;;
*) echo "" ;;
esac
}
# Strumień logów na stdout, zależnie od backendu.
emit() {
if [ "$BACKEND" = "vllm-gpu" ]; then
command -v docker >/dev/null 2>&1 || { echo "brak dockera - a backend vllm-gpu działa w kontenerze" >&2; exit 1; }
if [ "$FOLLOW" = "true" ]; then
docker compose -f "$COMPOSE_FILE" --profile vllm-gpu logs --follow --tail "$TAIL"
else
docker compose -f "$COMPOSE_FILE" --profile vllm-gpu logs --tail "$TAIL"
fi
return
fi
local file
file="$(log_file_for "$BACKEND")"
if [ -z "$file" ]; then
echo "backend '$BACKEND' nie ma znanego źródła logów" >&2
exit 1
fi
if [ ! -f "$file" ]; then
echo "brak pliku logu: $file" >&2
echo "backend prawdopodobnie nie był uruchamiany - zacznij od: task llm:up" >&2
exit 1
fi
if [ "$FOLLOW" = "true" ]; then
# --follow=name odtwarza plik po rotacji/nadpisaniu, np. przy restarcie backendu
tail -n "$TAIL" -F "$file"
else
tail -n "$TAIL" "$file"
fi
}
filter() {
if [ -n "$PATTERN" ]; then
# --line-buffered: przy śledzeniu linie mają się pojawiać od razu, a nie po zapełnieniu bufora
grep --line-buffered -Ei "$PATTERN" || true
else
cat
fi
}
if [ -n "$SAVE" ]; then
mkdir -p "$(dirname "$SAVE")"
emit | filter > "$SAVE"
echo "zapisano $(wc -l < "$SAVE" | tr -d ' ') linii do $SAVE"
elif [ "$FOLLOW" = "true" ]; then
emit | filter
else
# Jednorazowy zrzut: pusty wynik przy filtrze jest dwuznaczny (brak dopasowań
# czy brak logów?), więc mówimy to wprost zamiast milczeć.
output="$(emit | filter)"
if [ -z "$output" ] && [ -n "$PATTERN" ]; then
echo "brak linii pasujących do '$PATTERN' w ostatnich $TAIL liniach logu" >&2
else
printf '%s\n' "$output"
fi
fi
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# vLLM na Apple Silicon przez plugin vllm-project/vllm-metal (backend MLX).
#
# Docker na macOS nie ma dostępu do Metala, więc to jedyna ścieżka z akceleracją.
# Plugin instaluje się do własnego venva (~/.venv-vllm-metal) i wymaga natywnego
# arm64 Pythona 3.12 oraz Xcode Command Line Tools.
#
# Użycie: vllm-metal.sh up <model> <port> <log> <pid> | down <pid> | install
set -euo pipefail
VENV="${VLLM_METAL_VENV:-$HOME/.venv-vllm-metal}"
INSTALLER_URL="https://raw.githubusercontent.com/vllm-project/vllm-metal/main/install.sh"
fail() { echo "błąd: $*" >&2; exit 1; }
check_platform() {
[ "$(uname -s)" = "Darwin" ] || fail "ten backend działa wyłącznie na macOS (wykryto $(uname -s))"
[ "$(uname -m)" = "arm64" ] || fail "wymagany Apple Silicon (wykryto $(uname -m))"
xcode-select -p >/dev/null 2>&1 || fail "brak Xcode Command Line Tools - uruchom: xcode-select --install"
}
install_plugin() {
check_platform
if [ -x "$VENV/bin/vllm" ]; then
echo "vllm-metal już zainstalowany w $VENV"
return 0
fi
echo "instaluję vllm-metal do $VENV (to potrwa kilka minut)..."
curl -fsSL "$INSTALLER_URL" | bash
[ -x "$VENV/bin/vllm" ] || fail "instalacja nie powiodła się - sprawdź https://github.com/vllm-project/vllm-metal"
}
case "${1:-}" in
install)
install_plugin
;;
up)
model="${2:?podaj model}"; port="${3:-8000}"; log="${4:-.runs/llm/vllm-metal.log}"; pidfile="${5:-.runs/llm/vllm-metal.pid}"
install_plugin
mkdir -p "$(dirname "$log")" "$(dirname "$pidfile")"
if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
echo "vllm-metal już działa (pid $(cat "$pidfile"))"; exit 0
fi
echo "startuję vllm serve $model na porcie $port (log: $log)"
python3 "$(dirname "$0")/daemonize.py" --pidfile "$pidfile" --log "$log" -- \
"$VENV/bin/vllm" serve "$model" \
--port "$port" \
--max-model-len 16384 \
--enable-auto-tool-choice \
--tool-call-parser hermes
echo "pierwsze uruchomienie pobiera model - to może potrwać (log: $log)"
;;
down)
pidfile="${2:-.runs/llm/vllm-metal.pid}"
python3 "$(dirname "$0")/daemonize.py" --pidfile "$pidfile" --log /dev/null --stop
;;
*)
fail "użycie: $0 {install|up <model> <port> <log> <pid>|down <pid>}"
;;
esac
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Czeka, aż endpoint OpenAI odpowie na /v1/models. Pobranie modelu potrafi trwać,
# więc domyślny limit jest hojny, a komunikat mówi, gdzie zajrzeć.
set -euo pipefail
url="${1:?podaj base_url, np. http://localhost:8000/v1}"
timeout="${2:-900}"
log="${3:-}"
deadline=$(( $(date +%s) + timeout ))
printf 'czekam na %s/models ' "$url"
while [ "$(date +%s)" -lt "$deadline" ]; do
if curl -fsS --max-time 5 "$url/models" >/dev/null 2>&1; then
printf '\nendpoint gotowy: %s\n' "$url"
exit 0
fi
printf '.'
sleep 3
done
printf '\n'
echo "endpoint nie odpowiedział w ciągu ${timeout}s" >&2
[ -n "$log" ] && [ -f "$log" ] && { echo "--- ostatnie 30 linii $log:" >&2; tail -30 "$log" >&2; }
exit 1