feat: initial commit
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# Backendy LLM uruchamiane kontenerowo. Sterowane profilami, żeby jeden plik
|
||||
# obsłużył kilka wariantów i żeby `docker compose up` bez profilu nic nie robił.
|
||||
#
|
||||
# docker compose --profile vllm-gpu up -d # Linux + NVIDIA
|
||||
# docker compose --profile ollama up -d # awaryjnie, obie platformy
|
||||
#
|
||||
# Zwykle nie wywołujesz tego wprost - robi to `task llm:up`.
|
||||
|
||||
name: agentic-codemod-llm
|
||||
|
||||
services:
|
||||
|
||||
vllm:
|
||||
profiles: ["vllm-gpu"]
|
||||
image: vllm/vllm-openai:${VLLM_IMAGE_TAG:-latest}
|
||||
command: >
|
||||
--model ${LLM_SERVE_MODEL:-Qwen/Qwen3-4B-Instruct-2507}
|
||||
--max-model-len=16384
|
||||
--enable-auto-tool-choice
|
||||
--tool-call-parser=hermes
|
||||
--gpu-memory-utilization=${VLLM_GPU_UTIL:-0.90}
|
||||
ports:
|
||||
- "${LLM_PORT:-8000}:8000"
|
||||
volumes:
|
||||
# Cache modeli poza kontenerem - restart nie oznacza ponownego pobierania.
|
||||
- ${HF_CACHE:-${HOME}/.cache/huggingface}:/root/.cache/huggingface
|
||||
environment:
|
||||
HUGGING_FACE_HUB_TOKEN: ${HUGGING_FACE_HUB_TOKEN:-}
|
||||
# vLLM używa pamięci dzielonej do komunikacji między workerami
|
||||
ipc: host
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python3 -c \"import urllib.request;urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 40
|
||||
start_period: 300s
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
profiles: ["ollama"]
|
||||
image: ollama/ollama:${OLLAMA_IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- "${OLLAMA_PORT:-11434}:11434"
|
||||
volumes:
|
||||
- ollama-models:/root/.ollama
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
ollama-models:
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Atrapa serwera zgodnego z OpenAI - do testowania ścieżki agentowej bez modelu.
|
||||
|
||||
Po co to jest
|
||||
------------
|
||||
Tryb `--offline` sprawdza `RuleBrain`, ale nie dotyka tego, co w tym projekcie jest
|
||||
najbardziej kruche: budowy agentów z definicji APM, wywoływania narzędzi przez model
|
||||
i parsowania strukturalnych wyjść. Ta atrapa domyka lukę - pozwala przepuścić
|
||||
`LlmBrain` przez pełny przebieg na każdym MR, na Linuksie i na macOS, bez GPU
|
||||
i bez pobierania modelu.
|
||||
|
||||
Czym to NIE jest
|
||||
----------------
|
||||
To nie jest symulator modelu. Scenariusze są zestrojone z fixture'em `acme-app`:
|
||||
atrapa odpowiada z góry ustalonymi wywołaniami narzędzi i strukturami. Sprawdza,
|
||||
czy instalacja hydrauliczna trzyma wodę - nie czy model jest mądry.
|
||||
|
||||
Routing odpowiedzi po nazwie agenta z komunikatu systemowego ("Your name is: coder."),
|
||||
którą wstawia agno przy `add_name_to_context=True`.
|
||||
|
||||
Uruchomienie:
|
||||
python3 llm/mock_server.py --port 8077
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
|
||||
MODEL_ID = "mock/agentic-codemod"
|
||||
_NAME_RE = re.compile(r"Your name is:\s*([a-z0-9_-]+)", re.IGNORECASE)
|
||||
|
||||
# --------------------------------------------------------------------------- scenariusze
|
||||
|
||||
REPO_PROFILE = {
|
||||
"build_system": "python-pip",
|
||||
"dependency_files": ["pyproject.toml"],
|
||||
"declared_version": "==1.4.2",
|
||||
"module_name": "acme",
|
||||
"usage_files": ["src/acme_app/notifier.py"],
|
||||
"usage_symbols": ["Client", "send", "close"],
|
||||
"verify_command": "python -m pytest -q",
|
||||
"blast_radius": "low",
|
||||
"gaps": [],
|
||||
}
|
||||
|
||||
CHANGE_PLAN = {
|
||||
"summary": "Migracja acme-sdk 1.4.2 -> 2.1.0: zmiana klasy klienta, wysyłki i odczytu odpowiedzi.",
|
||||
"edits": [
|
||||
{
|
||||
"order": 1,
|
||||
"path": "src/acme_app/notifier.py",
|
||||
"intent": "Client -> AcmeClient, send() -> messages.create(), usunięcie close(), result['id'] -> result.id",
|
||||
"rationale": "Jedyny plik używający SDK; zmiany opisane w notatce migracyjnej 2.x.",
|
||||
"acceptance_criteria": "pytest kończy się kodem 0, brak wystąpień client.send( i .close()",
|
||||
"requires_human": False,
|
||||
"blocked_reason": None,
|
||||
}
|
||||
],
|
||||
"out_of_scope": ["pyproject.toml (wersję ustawia pipeline)", "stubs/acme (atrapa dostawcy)"],
|
||||
"risks": ["Brak testów integracyjnych z realnym dostawcą."],
|
||||
}
|
||||
|
||||
REVIEW_VERDICT = {
|
||||
"verdict": "approve",
|
||||
"summary": "Diff ograniczony do jednego pliku i zgodny z planem; testy nietknięte.",
|
||||
"findings": [
|
||||
{
|
||||
"severity": "info",
|
||||
"category": "scope",
|
||||
"path": "src/acme_app/notifier.py",
|
||||
"message": "Zmiany wyłącznie w warstwie integracji z SDK.",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
MERGE_REQUEST = {
|
||||
"title": "build(deps): acme-sdk 1.4.2 -> 2.1.0 wraz z migracją API",
|
||||
"description": (
|
||||
"## Co i dlaczego\n\n"
|
||||
"Podniesienie acme-sdk do 2.1.0 i dostosowanie wywołań do API 2.x.\n\n"
|
||||
"## Zakres zmian\n\n- `pyproject.toml` - deklaracja wersji\n"
|
||||
"- `src/acme_app/notifier.py` - migracja klienta i wysyłki\n\n"
|
||||
"## Weryfikacja\n\n`pytest -q` -> rc=0\n\n"
|
||||
"## Ryzyko i ograniczenia\n\n"
|
||||
"Przebieg wykonany na atrapie modelu - opis służy weryfikacji pipeline'u, nie ocenie zmiany.\n"
|
||||
),
|
||||
}
|
||||
|
||||
# Fragmenty zestrojone z examples/fixtures/acme-app - atrapa "wie", co zastąpić.
|
||||
_WELCOME_OLD = (
|
||||
" client = Client(api_key=api_key, endpoint=endpoint)\n"
|
||||
" result = client.send(to=email, body=WELCOME_BODY)\n"
|
||||
" client.close()\n"
|
||||
' return result["id"]'
|
||||
)
|
||||
_WELCOME_NEW = (
|
||||
" client = AcmeClient(api_key=api_key, base_url=endpoint)\n"
|
||||
" result = client.messages.create(recipient=email, content=WELCOME_BODY)\n"
|
||||
" return result.id"
|
||||
)
|
||||
_REMINDER_OLD = (
|
||||
" client = Client(api_key=api_key, endpoint=endpoint)\n"
|
||||
" try:\n"
|
||||
" result = client.send(to=email, body=body)\n"
|
||||
" except AcmeError:\n"
|
||||
" return None\n"
|
||||
" client.close()\n"
|
||||
' return result["id"]'
|
||||
)
|
||||
_REMINDER_NEW = (
|
||||
" client = AcmeClient(api_key=api_key, base_url=endpoint)\n"
|
||||
" try:\n"
|
||||
" result = client.messages.create(recipient=email, content=body)\n"
|
||||
" except AcmeError:\n"
|
||||
" return None\n"
|
||||
" return result.id"
|
||||
)
|
||||
|
||||
CODER_SCRIPT: list[dict[str, Any]] = [
|
||||
{"tool": "read_file", "args": {"path": "src/acme_app/notifier.py"}},
|
||||
{
|
||||
"tool": "replace_in_file",
|
||||
"args": {
|
||||
"path": "src/acme_app/notifier.py",
|
||||
"old_text": "from acme import Client",
|
||||
"new_text": "from acme import AcmeClient",
|
||||
},
|
||||
},
|
||||
{
|
||||
"tool": "replace_in_file",
|
||||
"args": {"path": "src/acme_app/notifier.py", "old_text": _WELCOME_OLD, "new_text": _WELCOME_NEW},
|
||||
},
|
||||
{
|
||||
"tool": "replace_in_file",
|
||||
"args": {"path": "src/acme_app/notifier.py", "old_text": _REMINDER_OLD, "new_text": _REMINDER_NEW},
|
||||
},
|
||||
{"tool": "run_verification", "args": {}},
|
||||
{
|
||||
"content": (
|
||||
"Plan wykonany. Zmieniony plik: src/acme_app/notifier.py "
|
||||
"(import, konstruktor klienta, wysyłka, odczyt identyfikatora). "
|
||||
"Weryfikacja zakończona powodzeniem."
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _agent_name(messages: list[dict[str, Any]]) -> str:
|
||||
for message in messages:
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
match = _NAME_RE.search(content)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _completed_tool_steps(messages: list[dict[str, Any]]) -> int:
|
||||
return sum(1 for m in messages if m.get("role") == "assistant" and m.get("tool_calls"))
|
||||
|
||||
|
||||
def _tool_call_message(step: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": f"call_{uuid.uuid4().hex[:12]}",
|
||||
"type": "function",
|
||||
"function": {"name": step["tool"], "arguments": json.dumps(step["args"], ensure_ascii=False)},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_message(messages: list[dict[str, Any]]) -> tuple[dict[str, Any], str]:
|
||||
"""Zwraca (wiadomość asystenta, finish_reason) dla danej rozmowy."""
|
||||
name = _agent_name(messages)
|
||||
|
||||
if name == "coder":
|
||||
index = _completed_tool_steps(messages)
|
||||
if index >= len(CODER_SCRIPT):
|
||||
return {"role": "assistant", "content": "Zakończono."}, "stop"
|
||||
step = CODER_SCRIPT[index]
|
||||
if "tool" in step:
|
||||
return _tool_call_message(step), "tool_calls"
|
||||
return {"role": "assistant", "content": step["content"]}, "stop"
|
||||
|
||||
payloads = {
|
||||
"scout": REPO_PROFILE,
|
||||
"planner": CHANGE_PLAN,
|
||||
"reviewer": REVIEW_VERDICT,
|
||||
"scribe": MERGE_REQUEST,
|
||||
}
|
||||
if name in payloads:
|
||||
return {"role": "assistant", "content": json.dumps(payloads[name], ensure_ascii=False)}, "stop"
|
||||
|
||||
if name == "unknown" and not any(m.get("role") == "system" for m in messages):
|
||||
# zwykłe zapytanie diagnostyczne (task llm:smoke), a nie agent z pipeline'u
|
||||
return {"role": "assistant", "content": "dziala (atrapa LLM)"}, "stop"
|
||||
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": f"Atrapa LLM nie ma scenariusza dla agenta '{name}'. Uzupełnij llm/mock_server.py.",
|
||||
}, "stop"
|
||||
|
||||
|
||||
class MockHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "agentic-codemod-mock/1.0"
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None: # cichy log, chyba że --verbose
|
||||
if getattr(self.server, "verbose", False):
|
||||
super().log_message(fmt, *args)
|
||||
|
||||
# ------------------------------------------------------------------ util
|
||||
def _send(self, payload: dict[str, Any], status: int = 200) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
# ------------------------------------------------------------------ HTTP
|
||||
def do_GET(self) -> None:
|
||||
if self.path.rstrip("/") in ("/healthz", "/v1/models", "/models"):
|
||||
if "models" in self.path:
|
||||
self._send({"object": "list", "data": [{"id": MODEL_ID, "object": "model", "owned_by": "mock"}]})
|
||||
else:
|
||||
self._send({"status": "ok", "model": MODEL_ID})
|
||||
return
|
||||
self._send({"error": {"message": f"nieobsługiwana ścieżka {self.path}"}}, status=404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if not self.path.rstrip("/").endswith("/chat/completions"):
|
||||
self._send({"error": {"message": f"nieobsługiwana ścieżka {self.path}"}}, status=404)
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
request = json.loads(self.rfile.read(length) or b"{}")
|
||||
|
||||
if request.get("stream"):
|
||||
# agno w tym pipelinie nie streamuje; jawny błąd jest lepszy niż ciche milczenie
|
||||
self._send({"error": {"message": "atrapa nie obsługuje stream=true"}}, status=400)
|
||||
return
|
||||
|
||||
messages = request.get("messages") or []
|
||||
message, finish_reason = build_message(messages)
|
||||
|
||||
# Jedna zwięzła linia na żądanie - dzięki temu `task llm:logs` pokazuje przebieg
|
||||
# rozmowy z agentami także na atrapie, a nie tylko przy realnym backendzie.
|
||||
tool = ""
|
||||
if message.get("tool_calls"):
|
||||
tool = " -> " + ", ".join(c["function"]["name"] for c in message["tool_calls"])
|
||||
print(
|
||||
f"[mock] agent={_agent_name(messages):<9} wiadomości={len(messages):<3} finish={finish_reason}{tool}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
self._send(
|
||||
{
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:16]}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": request.get("model") or MODEL_ID,
|
||||
"choices": [{"index": 0, "message": message, "finish_reason": finish_reason, "logprobs": None}],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def serve(host: str = "127.0.0.1", port: int = 8077, verbose: bool = False) -> ThreadingHTTPServer:
|
||||
httpd = ThreadingHTTPServer((host, port), MockHandler)
|
||||
httpd.verbose = verbose # type: ignore[attr-defined]
|
||||
httpd.daemon_threads = True
|
||||
return httpd
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Atrapa serwera OpenAI dla testów ścieżki agentowej")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8077)
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
httpd = serve(args.host, args.port, args.verbose)
|
||||
print(f"atrapa LLM: http://{args.host}:{args.port}/v1 (model: {MODEL_ID})", flush=True)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
# Jedyne źródło prawdy o backendach LLM i modelach per rola.
|
||||
#
|
||||
# Kontrakt jest jeden: endpoint zgodny z API OpenAI pod CODEMOD_LLM_BASE_URL.
|
||||
# Backendy różnią się tym, jak ten endpoint powstaje - nie tym, co widzi silnik.
|
||||
#
|
||||
# `task llm:env` renderuje z tego pliku zmienne środowiskowe,
|
||||
# `task llm:up` uruchamia wskazany backend.
|
||||
|
||||
default_backend: auto # auto = wykrycie platformy (macOS -> vllm-metal, Linux+NVIDIA -> vllm-gpu)
|
||||
|
||||
backends:
|
||||
|
||||
vllm-gpu:
|
||||
description: vLLM w kontenerze, Linux z GPU NVIDIA. Ścieżka najbliższa produkcji na OpenShifcie.
|
||||
platforms: [linux]
|
||||
requires: [docker, nvidia-gpu]
|
||||
provider: vllm
|
||||
base_url: http://localhost:8000/v1
|
||||
api_key: not-required
|
||||
serve:
|
||||
model: Qwen/Qwen3-4B-Instruct-2507
|
||||
# --enable-auto-tool-choice + parser są konieczne: agenci wołają narzędzia,
|
||||
# bez tego vLLM zwróci opis wywołania w treści zamiast tool_calls.
|
||||
args:
|
||||
- --max-model-len=16384
|
||||
- --enable-auto-tool-choice
|
||||
- --tool-call-parser=hermes
|
||||
- --gpu-memory-utilization=0.90
|
||||
models:
|
||||
planner: Qwen/Qwen3-4B-Instruct-2507
|
||||
coder: Qwen/Qwen3-4B-Instruct-2507
|
||||
reviewer: Qwen/Qwen3-4B-Instruct-2507
|
||||
scribe: Qwen/Qwen3-4B-Instruct-2507
|
||||
|
||||
vllm-metal:
|
||||
description: >
|
||||
vLLM natywnie na Apple Silicon przez plugin vllm-project/vllm-metal (backend MLX).
|
||||
Docker na macOS nie ma dostępu do Metala, więc kontener nie jest tu opcją.
|
||||
platforms: [darwin]
|
||||
requires: [python3.12-arm64, xcode-cli]
|
||||
provider: vllm
|
||||
base_url: http://localhost:8000/v1
|
||||
api_key: not-required
|
||||
serve:
|
||||
model: mlx-community/Qwen3-4B-Instruct-2507-4bit
|
||||
args:
|
||||
- --max-model-len=16384
|
||||
- --enable-auto-tool-choice
|
||||
- --tool-call-parser=hermes
|
||||
models:
|
||||
planner: mlx-community/Qwen3-4B-Instruct-2507-4bit
|
||||
coder: mlx-community/Qwen3-4B-Instruct-2507-4bit
|
||||
reviewer: mlx-community/Qwen3-4B-Instruct-2507-4bit
|
||||
scribe: mlx-community/Qwen3-4B-Instruct-2507-4bit
|
||||
|
||||
ollama:
|
||||
description: >
|
||||
Wariant awaryjny na obie platformy. Nie jest vLLM, ale wystawia /v1 zgodne z OpenAI
|
||||
i wstaje jedną komendą. Na macOS instaluj natywnie (Metal) - w Dockerze liczy na CPU.
|
||||
platforms: [linux, darwin]
|
||||
requires: [ollama]
|
||||
provider: openai_like
|
||||
base_url: http://localhost:11434/v1
|
||||
api_key: ollama
|
||||
serve:
|
||||
model: qwen3:4b-instruct
|
||||
args: []
|
||||
models:
|
||||
planner: qwen3:4b-instruct
|
||||
coder: qwen3:4b-instruct
|
||||
reviewer: qwen3:4b-instruct
|
||||
scribe: qwen3:4b-instruct
|
||||
|
||||
mock:
|
||||
description: >
|
||||
Atrapa z llm/mock_server.py. Zero pobierania, zero GPU, deterministyczne odpowiedzi
|
||||
zestrojone z fixture'em acme-app. Do testów ścieżki agentowej w CI, nie do pracy.
|
||||
platforms: [linux, darwin, windows]
|
||||
requires: []
|
||||
provider: openai_like
|
||||
base_url: http://127.0.0.1:8077/v1
|
||||
api_key: mock
|
||||
serve:
|
||||
model: mock/agentic-codemod
|
||||
args: []
|
||||
models:
|
||||
planner: mock/agentic-codemod
|
||||
coder: mock/agentic-codemod
|
||||
reviewer: mock/agentic-codemod
|
||||
scribe: mock/agentic-codemod
|
||||
|
||||
# Rekomendacje przy dobieraniu większych modeli (rola -> czego wymaga):
|
||||
# coder - najważniejszy: stabilne tool-calling i trzymanie się formatu edycji
|
||||
# planner - rozumowanie o zakresie zmiany; zysk z większego modelu jest tu wyraźny
|
||||
# reviewer - może być ten sam co planner; osobny model bywa lepszy (inny punkt widzenia)
|
||||
# scribe - najmniejszy wystarczy, to redakcja tekstu
|
||||
Executable
+141
@@ -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())
|
||||
Executable
+80
@@ -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())
|
||||
Executable
+108
@@ -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
|
||||
Executable
+60
@@ -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
|
||||
Executable
+22
@@ -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
|
||||
Reference in New Issue
Block a user