Rebuild profile router as secure recoverable v2
This commit is contained in:
@@ -52,6 +52,7 @@ llama.cpp :8080 + profilabhängige MCP-Server
|
|||||||
- [Saubere Installation](docs/INSTALLATION.md)
|
- [Saubere Installation](docs/INSTALLATION.md)
|
||||||
- [Betrieb und Profilwechsel](docs/OPERATIONS.md)
|
- [Betrieb und Profilwechsel](docs/OPERATIONS.md)
|
||||||
- [Sicherheitsmodell](docs/SECURITY.md)
|
- [Sicherheitsmodell](docs/SECURITY.md)
|
||||||
|
- [Router V2: Migration und Kompatibilität](docs/ROUTER_V2_MIGRATION.md)
|
||||||
- [Migration vom bestehenden Host](docs/MIGRATION.md)
|
- [Migration vom bestehenden Host](docs/MIGRATION.md)
|
||||||
- [MCP-Aufteilung](platform/mcp/README.md)
|
- [MCP-Aufteilung](platform/mcp/README.md)
|
||||||
- [llama.cpp-Build und Profile](platform/llama/README.md)
|
- [llama.cpp-Build und Profile](platform/llama/README.md)
|
||||||
@@ -92,11 +93,13 @@ Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel).
|
|||||||
| Endpunkt | Beschreibung |
|
| Endpunkt | Beschreibung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /v1/models` | Die drei virtuellen Modelle inkl. `context_length`/`context_window` |
|
| `GET /v1/models` | Die drei virtuellen Modelle inkl. `context_length`/`context_window` |
|
||||||
|
| `GET /health` | öffentliche Liveness-Prüfung des Routerprozesses |
|
||||||
|
| `GET /ready` | öffentliche Readiness-Prüfung von Router + Textmodell |
|
||||||
| `GET /status` | Aktives Profil, Upstream-Zustand, Modell, Kontext, Uptime |
|
| `GET /status` | Aktives Profil, Upstream-Zustand, Modell, Kontext, Uptime |
|
||||||
| `POST /fast` `/medium` `/long` | Profilwechsel (auch `GET` möglich) |
|
| `POST /fast` `/medium` `/long` | expliziter Profilwechsel |
|
||||||
| `POST /v1/chat/completions` | Weiterleitung an llama.cpp (Streaming + Tool Calls) |
|
| `POST /v1/chat/completions` | Weiterleitung an llama.cpp (Streaming + Tool Calls) |
|
||||||
| `POST /v1/images/generations` | Bildgenerierung (FLUX.2 [klein] 4B Base, OpenAI-kompatibel) |
|
| `POST /v1/images/generations` | Bildgenerierung (FLUX.2 [klein] 4B Base, OpenAI-kompatibel) |
|
||||||
| `GET /images` | Liste der gespeicherten Bilder (max. 200) |
|
| `GET /images` | Liste der aufbewahrten Bilder |
|
||||||
| `GET /images/<datei>` | PNG-Download (nur `images/`-Verzeichnis, validiert) |
|
| `GET /images/<datei>` | PNG-Download (nur `images/`-Verzeichnis, validiert) |
|
||||||
| `POST /v1/audio/speech` | Sprachausgabe (XTTS-v2, OpenAI-kompatibel) |
|
| `POST /v1/audio/speech` | Sprachausgabe (XTTS-v2, OpenAI-kompatibel) |
|
||||||
| `POST /v1/audio/transcriptions` | Deutsche Spracherkennung (whisper.cpp, OpenAI-kompatibel) |
|
| `POST /v1/audio/transcriptions` | Deutsche Spracherkennung (whisper.cpp, OpenAI-kompatibel) |
|
||||||
@@ -105,6 +108,12 @@ Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel).
|
|||||||
| `POST /vision/test` | Direkter Vision-Test (Bild + Frage → Q3-Analyse) |
|
| `POST /vision/test` | Direkter Vision-Test (Bild + Frage → Q3-Analyse) |
|
||||||
| alles andere | Transparente Weiterleitung an llama.cpp |
|
| alles andere | Transparente Weiterleitung an llama.cpp |
|
||||||
|
|
||||||
|
Bis auf `GET /health` und `GET /ready` benötigen alle Endpunkte einen
|
||||||
|
Router-API-Key als `Authorization: Bearer …` oder `X-API-Key`. Der Installer
|
||||||
|
erzeugt ihn einmalig in `/etc/mike-ai/router-api-key` und gibt ihn niemals im
|
||||||
|
Installationslog aus. Ein fehlender oder zu kurzer Schlüssel verhindert den
|
||||||
|
Produktivstart (fail closed).
|
||||||
|
|
||||||
### Verhalten
|
### Verhalten
|
||||||
|
|
||||||
- **Virtuelles Modell** (`qwen-fast`/`qwen-medium`/`qwen-long` in
|
- **Virtuelles Modell** (`qwen-fast`/`qwen-medium`/`qwen-long` in
|
||||||
@@ -159,6 +168,7 @@ Beispiel:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://AI_HOST:8081/v1/images/generations \
|
curl -s http://AI_HOST:8081/v1/images/generations \
|
||||||
|
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"prompt":"ein roter Würfel auf weißem Grund","size":"1024x1024","quality":"standard"}'
|
-d '{"prompt":"ein roter Würfel auf weißem Grund","size":"1024x1024","quality":"standard"}'
|
||||||
```
|
```
|
||||||
@@ -306,11 +316,13 @@ Beispiele:
|
|||||||
```bash
|
```bash
|
||||||
# MP3 (Default), Stimme claribel
|
# MP3 (Default), Stimme claribel
|
||||||
curl -s http://AI_HOST:8081/v1/audio/speech \
|
curl -s http://AI_HOST:8081/v1/audio/speech \
|
||||||
|
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"input":"Hallo, dies ist ein Test.","voice":"claribel"}' -o out.mp3
|
-d '{"input":"Hallo, dies ist ein Test.","voice":"claribel"}' -o out.mp3
|
||||||
|
|
||||||
# WAV, 1.5x Tempo
|
# WAV, 1.5x Tempo
|
||||||
curl -s http://AI_HOST:8081/v1/audio/speech \
|
curl -s http://AI_HOST:8081/v1/audio/speech \
|
||||||
|
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"input":"Guten Tag.","voice":"claribel","speed":1.5,"response_format":"wav"}' -o out.wav
|
-d '{"input":"Guten Tag.","voice":"claribel","speed":1.5,"response_format":"wav"}' -o out.wav
|
||||||
```
|
```
|
||||||
@@ -400,17 +412,20 @@ Beispiele:
|
|||||||
```bash
|
```bash
|
||||||
# WebM/Opus (z.B. aus Open WebUI-Mikrofon)
|
# WebM/Opus (z.B. aus Open WebUI-Mikrofon)
|
||||||
curl -s http://AI_HOST:8081/v1/audio/transcriptions \
|
curl -s http://AI_HOST:8081/v1/audio/transcriptions \
|
||||||
|
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||||
-F "file=@aufnahme.webm" \
|
-F "file=@aufnahme.webm" \
|
||||||
-F "model=whisper-1"
|
-F "model=whisper-1"
|
||||||
|
|
||||||
# WAV mit expliziter Sprache
|
# WAV mit expliziter Sprache
|
||||||
curl -s http://AI_HOST:8081/v1/audio/transcriptions \
|
curl -s http://AI_HOST:8081/v1/audio/transcriptions \
|
||||||
|
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||||
-F "file=@aufnahme.wav" \
|
-F "file=@aufnahme.wav" \
|
||||||
-F "model=whisper-1" \
|
-F "model=whisper-1" \
|
||||||
-F "language=de"
|
-F "language=de"
|
||||||
|
|
||||||
# Verbose-Format
|
# Verbose-Format
|
||||||
curl -s http://AI_HOST:8081/v1/audio/transcriptions \
|
curl -s http://AI_HOST:8081/v1/audio/transcriptions \
|
||||||
|
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||||
-F "file=@aufnahme.wav" \
|
-F "file=@aufnahme.wav" \
|
||||||
-F "model=whisper-1" \
|
-F "model=whisper-1" \
|
||||||
-F "response_format=verbose_json"
|
-F "response_format=verbose_json"
|
||||||
@@ -510,6 +525,11 @@ Die Installation ist idempotent (Update = erneut ausführen).
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ROUTER_HOST` | `0.0.0.0` | Bind-Adresse |
|
| `ROUTER_HOST` | `0.0.0.0` | Bind-Adresse |
|
||||||
| `ROUTER_PORT` | `8081` | Port |
|
| `ROUTER_PORT` | `8081` | Port |
|
||||||
|
| `ROUTER_AUTH_MODE` | `required` | Authentifizierung; `off` nur für lokale Tests |
|
||||||
|
| `ROUTER_API_KEY_FILE` | `/etc/mike-ai/router-api-key` | Schlüsseldatei (0600) |
|
||||||
|
| `ROUTER_STATE_FILE` | `/var/lib/mike-ai-profile-router/state.json` | atomarer Crash-/Recovery-Zustand |
|
||||||
|
| `ROUTER_PROFILES_FILE` | `/etc/mike-ai/router-profiles.json` | Profile, Kontext und erwarteter Alias |
|
||||||
|
| `ROUTER_MAX_CONCURRENT_REQUESTS` | `16` | harte Grenze paralleler Requests |
|
||||||
| `UPSTREAM_URL` | `http://127.0.0.1:8080` | llama.cpp |
|
| `UPSTREAM_URL` | `http://127.0.0.1:8080` | llama.cpp |
|
||||||
| `PROFILE_SCRIPT` | `/usr/local/bin/llama-profile` | Profil-Skript |
|
| `PROFILE_SCRIPT` | `/usr/local/bin/llama-profile` | Profil-Skript |
|
||||||
| `PROFILE_DIR` | `/etc/systemd/system/mike-ai-llama-ui.service.d` | Ort der `override.conf` |
|
| `PROFILE_DIR` | `/etc/systemd/system/mike-ai-llama-ui.service.d` | Ort der `override.conf` |
|
||||||
@@ -538,6 +558,8 @@ Die Installation ist idempotent (Update = erneut ausführen).
|
|||||||
| `VISION_UNLOAD_TIMEOUT` | `120` | Warten auf VRAM-Freiheit (s) |
|
| `VISION_UNLOAD_TIMEOUT` | `120` | Warten auf VRAM-Freiheit (s) |
|
||||||
| `VISION_MAX_TOKENS` | `4096` | Max. Tokens der Vision-Analyse |
|
| `VISION_MAX_TOKENS` | `4096` | Max. Tokens der Vision-Analyse |
|
||||||
| `VISION_CACHE_MAX` | `64` | Größe des Analyse-Caches (LRU) |
|
| `VISION_CACHE_MAX` | `64` | Größe des Analyse-Caches (LRU) |
|
||||||
|
| `VISION_MAX_IMAGE_BYTES` | `20971520` | maximales dekodiertes Bild (20 MiB) |
|
||||||
|
| `VISION_ALLOW_REMOTE_URLS` | `false` | externe Bild-URLs; standardmäßig SSRF-sicher aus |
|
||||||
| `VISION_LOG` | `/opt/mike-ai/ai-profile-router/vision_server.log` | Vision-Server-Log |
|
| `VISION_LOG` | `/opt/mike-ai/ai-profile-router/vision_server.log` | Vision-Server-Log |
|
||||||
|
|
||||||
TTS-Worker (`mike-ai-xtts.service`):
|
TTS-Worker (`mike-ai-xtts.service`):
|
||||||
@@ -567,7 +589,8 @@ paralleler Chat während Bild-Job wartet statt 502) und **Sprachausgabe**
|
|||||||
(`/status` mit tts-Section, `POST /v1/audio/speech` wav/mp3, Validierung,
|
(`/status` mit tts-Section, `POST /v1/audio/speech` wav/mp3, Validierung,
|
||||||
Worker-Fehler→503, Worker down→503, Worker-Neustart→Recovery).
|
Worker-Fehler→503, Worker down→503, Worker-Neustart→Recovery).
|
||||||
|
|
||||||
Aktuell: **43 Tests** (32 bestehende + 11 TTS-Assertions).
|
Aktuell: **63 Integrationsassertions** plus Chunked-, Multipart- und
|
||||||
|
Security/Recovery-Unit-Tests.
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
@@ -576,10 +599,12 @@ systemctl status mike-ai-profile-router
|
|||||||
systemctl status mike-ai-xtts
|
systemctl status mike-ai-xtts
|
||||||
journalctl -u mike-ai-profile-router -f
|
journalctl -u mike-ai-profile-router -f
|
||||||
journalctl -u mike-ai-xtts -f
|
journalctl -u mike-ai-xtts -f
|
||||||
curl -s http://AI_HOST:8081/status | python3 -m json.tool
|
ROUTER_KEY='aus lokalem Secret-Store'
|
||||||
curl -s -X POST http://AI_HOST:8081/medium
|
curl -s http://AI_HOST:8081/status -H "Authorization: Bearer $ROUTER_KEY" | python3 -m json.tool
|
||||||
|
curl -s -X POST http://AI_HOST:8081/medium -H "Authorization: Bearer $ROUTER_KEY"
|
||||||
# TTS-Test
|
# TTS-Test
|
||||||
curl -s http://AI_HOST:8081/v1/audio/speech \
|
curl -s http://AI_HOST:8081/v1/audio/speech \
|
||||||
|
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"input":"Hallo","voice":"claribel"}' -o test.mp3
|
-d '{"input":"Hallo","voice":"claribel"}' -o test.mp3
|
||||||
```
|
```
|
||||||
@@ -589,7 +614,12 @@ curl -s http://AI_HOST:8081/v1/audio/speech \
|
|||||||
- Keine Shell-Aufrufe: Profil-Skript wird mit `subprocess.run([script, profil])`
|
- Keine Shell-Aufrufe: Profil-Skript wird mit `subprocess.run([script, profil])`
|
||||||
aufgerufen, `profil` ist Whitelist-geprüft (`fast|medium|long`).
|
aufgerufen, `profil` ist Whitelist-geprüft (`fast|medium|long`).
|
||||||
- Keine Secrets/Tokens im Code oder in der Unit.
|
- Keine Secrets/Tokens im Code oder in der Unit.
|
||||||
- systemd-Hardening: `NoNewPrivileges=true`, `PrivateTmp=true`.
|
- API-Key-Pflicht, konstante Schlüsselprüfung und keine Weitergabe des
|
||||||
|
Router-Schlüssels an llama.cpp.
|
||||||
|
- Remote-Bilder standardmäßig gesperrt; lokale Data-URLs sind typ- und
|
||||||
|
größenvalidiert.
|
||||||
|
- systemd-Hardening: `NoNewPrivileges`, `PrivateTmp`, `ProtectHome`,
|
||||||
|
Kernel-/Control-Group-Schutz und restriktive Dateirechte.
|
||||||
- Die bestehende llama.cpp-/Profil-Konfiguration wird nicht verändert; der
|
- Die bestehende llama.cpp-/Profil-Konfiguration wird nicht verändert; der
|
||||||
Router nutzt nur das vorhandene `llama-profile`-Skript und liest die
|
Router nutzt nur das vorhandene `llama-profile`-Skript und liest die
|
||||||
`override.conf`.
|
`override.conf`.
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@ SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}"
|
|||||||
STAGE="/tmp/ai-profile-router-$$"
|
STAGE="/tmp/ai-profile-router-$$"
|
||||||
|
|
||||||
mkdir -p "$STAGE"
|
mkdir -p "$STAGE"
|
||||||
cp router/ai_profile_router.py router/image_worker.py router/xtts_worker.py \
|
cp router/ai_profile_router.py router/router_support.py router/router_profiles.json \
|
||||||
|
router/image_worker.py router/xtts_worker.py \
|
||||||
router/stt_worker.py \
|
router/stt_worker.py \
|
||||||
deploy/install.sh deploy/mike-ai-profile-router.service \
|
deploy/install.sh deploy/mike-ai-profile-router.service \
|
||||||
deploy/mike-ai-xtts.service deploy/mike-ai-whisper.service \
|
deploy/mike-ai-xtts.service deploy/mike-ai-whisper.service \
|
||||||
|
|||||||
+24
-1
@@ -38,7 +38,10 @@ fi
|
|||||||
|
|
||||||
# --- 2. Neue Dateien installieren -------------------------------------------
|
# --- 2. Neue Dateien installieren -------------------------------------------
|
||||||
mkdir -p "$INSTALL_DIR" "$IMAGE_DIR"
|
mkdir -p "$INSTALL_DIR" "$IMAGE_DIR"
|
||||||
|
install -d -m 0700 /etc/mike-ai /var/lib/mike-ai-profile-router
|
||||||
install -m 0755 "$DIR/ai_profile_router.py" "$INSTALL_DIR/ai_profile_router.py"
|
install -m 0755 "$DIR/ai_profile_router.py" "$INSTALL_DIR/ai_profile_router.py"
|
||||||
|
install -m 0644 "$DIR/router_support.py" "$INSTALL_DIR/router_support.py"
|
||||||
|
install -m 0644 "$DIR/router_profiles.json" /etc/mike-ai/router-profiles.json
|
||||||
install -m 0755 "$DIR/image_worker.py" "$INSTALL_DIR/image_worker.py"
|
install -m 0755 "$DIR/image_worker.py" "$INSTALL_DIR/image_worker.py"
|
||||||
install -m 0755 "$DIR/xtts_worker.py" "$INSTALL_DIR/xtts_worker.py"
|
install -m 0755 "$DIR/xtts_worker.py" "$INSTALL_DIR/xtts_worker.py"
|
||||||
install -m 0755 "$DIR/stt_worker.py" "$INSTALL_DIR/stt_worker.py"
|
install -m 0755 "$DIR/stt_worker.py" "$INSTALL_DIR/stt_worker.py"
|
||||||
@@ -46,6 +49,14 @@ install -m 0644 "$DIR/${SERVICE}" "/etc/systemd/system/${SERVICE}"
|
|||||||
install -m 0644 "$DIR/${XTTS_SERVICE}" "/etc/systemd/system/${XTTS_SERVICE}"
|
install -m 0644 "$DIR/${XTTS_SERVICE}" "/etc/systemd/system/${XTTS_SERVICE}"
|
||||||
install -m 0644 "$DIR/${WHISPER_SERVICE}" "/etc/systemd/system/${WHISPER_SERVICE}"
|
install -m 0644 "$DIR/${WHISPER_SERVICE}" "/etc/systemd/system/${WHISPER_SERVICE}"
|
||||||
|
|
||||||
|
# Eigener Router-Key. Er wird niemals in Git oder ins Installationslog geschrieben.
|
||||||
|
if [ ! -s /etc/mike-ai/router-api-key ]; then
|
||||||
|
umask 077
|
||||||
|
python3 -c 'import secrets; print(secrets.token_urlsafe(48))' \
|
||||||
|
> /etc/mike-ai/router-api-key
|
||||||
|
fi
|
||||||
|
chmod 0600 /etc/mike-ai/router-api-key
|
||||||
|
|
||||||
# --- 3. Python-Venv mit Bild-Abhängigkeiten ---------------------------------
|
# --- 3. Python-Venv mit Bild-Abhängigkeiten ---------------------------------
|
||||||
if [ ! -x "$VENV/bin/python" ]; then
|
if [ ! -x "$VENV/bin/python" ]; then
|
||||||
echo "-- Erstelle Python-Venv in $VENV"
|
echo "-- Erstelle Python-Venv in $VENV"
|
||||||
@@ -150,5 +161,17 @@ if ! systemctl is-active --quiet "$WHISPER_SERVICE"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "-- Whisper-Service läuft"
|
echo "-- Whisper-Service läuft"
|
||||||
curl -sf "http://127.0.0.1:8081/status" | python3 -m json.tool
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
key = pathlib.Path("/etc/mike-ai/router-api-key").read_text().strip()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
"http://127.0.0.1:8081/status",
|
||||||
|
headers={"Authorization": f"Bearer {key}"})
|
||||||
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
|
print(json.dumps(json.load(response), indent=2))
|
||||||
|
PY
|
||||||
|
echo "-- Router-API-Key liegt ausschließlich in /etc/mike-ai/router-api-key"
|
||||||
echo "== Fertig =="
|
echo "== Fertig =="
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ Type=simple
|
|||||||
ExecStart=/opt/mike-ai/ai-profile-router/venv/bin/python /opt/mike-ai/ai-profile-router/ai_profile_router.py
|
ExecStart=/opt/mike-ai/ai-profile-router/venv/bin/python /opt/mike-ai/ai-profile-router/ai_profile_router.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
EnvironmentFile=-/etc/mike-ai/router.env
|
||||||
Environment=ROUTER_HOST=0.0.0.0
|
Environment=ROUTER_HOST=0.0.0.0
|
||||||
Environment=ROUTER_PORT=8081
|
Environment=ROUTER_PORT=8081
|
||||||
|
Environment=ROUTER_AUTH_MODE=required
|
||||||
|
Environment=ROUTER_API_KEY_FILE=/etc/mike-ai/router-api-key
|
||||||
|
Environment=ROUTER_STATE_FILE=/var/lib/mike-ai-profile-router/state.json
|
||||||
|
Environment=ROUTER_PROFILES_FILE=/etc/mike-ai/router-profiles.json
|
||||||
|
Environment=ROUTER_MAX_CONCURRENT_REQUESTS=16
|
||||||
Environment=UPSTREAM_URL=http://127.0.0.1:8080
|
Environment=UPSTREAM_URL=http://127.0.0.1:8080
|
||||||
Environment=PROFILE_SCRIPT=/usr/local/bin/llama-profile
|
Environment=PROFILE_SCRIPT=/usr/local/bin/llama-profile
|
||||||
Environment=PROFILE_DIR=/etc/systemd/system/mike-ai-llama-ui.service.d
|
Environment=PROFILE_DIR=/etc/systemd/system/mike-ai-llama-ui.service.d
|
||||||
@@ -22,6 +28,9 @@ Environment=IMAGE_DIR=/opt/mike-ai/ai-profile-router/images
|
|||||||
Environment=IMAGE_WORKER_LOG=/opt/mike-ai/ai-profile-router/worker.log
|
Environment=IMAGE_WORKER_LOG=/opt/mike-ai/ai-profile-router/worker.log
|
||||||
Environment=IMAGE_GEN_TIMEOUT=600
|
Environment=IMAGE_GEN_TIMEOUT=600
|
||||||
Environment=IMAGE_VRAM_FREE_TIMEOUT=120
|
Environment=IMAGE_VRAM_FREE_TIMEOUT=120
|
||||||
|
Environment=IMAGE_RETENTION_FILES=100
|
||||||
|
Environment=IMAGE_RETENTION_BYTES=5368709120
|
||||||
|
Environment=IMAGE_RETENTION_DAYS=30
|
||||||
Environment=LLAMA_SERVER_BIN=/opt/mike-ai/llama.cpp/build/bin/llama-server
|
Environment=LLAMA_SERVER_BIN=/opt/mike-ai/llama.cpp/build/bin/llama-server
|
||||||
Environment=VISION_MODEL=/opt/mike-ai/models/qwen3.8-27b/Qwen3.8-27B-Q3_K_M.gguf
|
Environment=VISION_MODEL=/opt/mike-ai/models/qwen3.8-27b/Qwen3.8-27B-Q3_K_M.gguf
|
||||||
Environment=VISION_MMPROJ=/opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf
|
Environment=VISION_MMPROJ=/opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf
|
||||||
@@ -31,9 +40,20 @@ Environment=VISION_LOAD_TIMEOUT=300
|
|||||||
Environment=VISION_INFER_TIMEOUT=300
|
Environment=VISION_INFER_TIMEOUT=300
|
||||||
Environment=VISION_UNLOAD_TIMEOUT=120
|
Environment=VISION_UNLOAD_TIMEOUT=120
|
||||||
Environment=VISION_MAX_TOKENS=4096
|
Environment=VISION_MAX_TOKENS=4096
|
||||||
|
Environment=VISION_MAX_IMAGE_BYTES=20971520
|
||||||
|
Environment=VISION_ALLOW_REMOTE_URLS=false
|
||||||
Environment=VISION_LOG=/opt/mike-ai/ai-profile-router/vision_server.log
|
Environment=VISION_LOG=/opt/mike-ai/ai-profile-router/vision_server.log
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -9,5 +9,11 @@ case "${1:-}" in
|
|||||||
*) echo "Usage: fake-llama-profile {fast|medium|long}"; exit 1 ;;
|
*) echo "Usage: fake-llama-profile {fast|medium|long}"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
if [ -f /tmp/fake-profile-fail ] \
|
||||||
|
&& [ "$(cat /tmp/fake-profile-fail)" = "$1" ]; then
|
||||||
|
echo "fake: simulierter Profilfehler für '$1'"
|
||||||
|
exit 42
|
||||||
|
fi
|
||||||
|
|
||||||
cp "$D/profile-$1.conf.disabled" "$D/override.conf"
|
cp "$D/profile-$1.conf.disabled" "$D/override.conf"
|
||||||
echo "fake: Profil '$1' gesetzt"
|
echo "fake: Profil '$1' gesetzt"
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._json(404, {"error": {"message": "not found"}})
|
self._json(404, {"error": {"message": "not found"}})
|
||||||
|
|
||||||
def _completion(self, body: dict) -> dict:
|
def _completion(self, body: dict) -> dict:
|
||||||
|
delay = body.get("mock_delay", 0)
|
||||||
|
if isinstance(delay, (int, float)) and 0 < delay <= 10:
|
||||||
|
time.sleep(delay)
|
||||||
model = body.get("model")
|
model = body.get("model")
|
||||||
if body.get("tools"):
|
if body.get("tools"):
|
||||||
name = body["tools"][0]["function"]["name"]
|
name = body["tools"][0]["function"]["name"]
|
||||||
@@ -84,6 +87,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"finish_reason": finish}],
|
"finish_reason": finish}],
|
||||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5,
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5,
|
||||||
"total_tokens": 15},
|
"total_tokens": 15},
|
||||||
|
"mock_ctx": current_ctx(),
|
||||||
|
"mock_authorization": self.headers.get("Authorization"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _stream(self, body: dict) -> None:
|
def _stream(self, body: dict) -> None:
|
||||||
|
|||||||
@@ -176,10 +176,17 @@ def cleanup() -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
global procs
|
global procs
|
||||||
|
try:
|
||||||
|
os.unlink("/tmp/test-chunked-router-state.json")
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.update({
|
env.update({
|
||||||
"ROUTER_HOST": "127.0.0.1",
|
"ROUTER_HOST": "127.0.0.1",
|
||||||
"ROUTER_PORT": str(PORTS["router"]),
|
"ROUTER_PORT": str(PORTS["router"]),
|
||||||
|
"ROUTER_AUTH_MODE": "off",
|
||||||
|
"ROUTER_PROFILES_FILE": "",
|
||||||
|
"ROUTER_STATE_FILE": "/tmp/test-chunked-router-state.json",
|
||||||
"UPSTREAM_URL": f"http://127.0.0.1:{PORTS['llama']}",
|
"UPSTREAM_URL": f"http://127.0.0.1:{PORTS['llama']}",
|
||||||
"PROFILE_SCRIPT": FAKE_PROFILE,
|
"PROFILE_SCRIPT": FAKE_PROFILE,
|
||||||
"PROFILE_DIR": FAKE_PROFILE_DIR,
|
"PROFILE_DIR": FAKE_PROFILE_DIR,
|
||||||
|
|||||||
+91
-13
@@ -4,18 +4,27 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
UP_PORT=18080
|
UP_PORT="${UP_PORT:-18080}"
|
||||||
RT_PORT=18081
|
RT_PORT="${RT_PORT:-18081}"
|
||||||
TTS_PORT=18082
|
TTS_PORT="${TTS_PORT:-18082}"
|
||||||
STT_PORT=18083
|
STT_PORT="${STT_PORT:-18083}"
|
||||||
BASE="http://127.0.0.1:$RT_PORT"
|
BASE="http://127.0.0.1:$RT_PORT"
|
||||||
FAKE_DIR="$PWD/dev/fake-profile-dir"
|
FAKE_DIR="$PWD/dev/fake-profile-dir"
|
||||||
|
TEST_ROUTER_KEY="test-router-key-0123456789-abcdefghijklmnopqrstuvwxyz"
|
||||||
PASS=0
|
PASS=0
|
||||||
FAIL=0
|
FAIL=0
|
||||||
|
|
||||||
|
# Produktive Authentifizierung für alle Integrationstests. `command curl`
|
||||||
|
# umgeht diese Funktion bei den gezielten anonymen Negativtests.
|
||||||
|
curl() { command curl -H "Authorization: Bearer $TEST_ROUTER_KEY" "$@"; }
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
kill "${MOCK_PID:-}" "${ROUTER_PID:-}" "${TTS_PID:-}" "${STT_PID:-}" 2>/dev/null || true
|
CURRENT_MOCK_PID="$(cat /tmp/mock_upstream_pid 2>/dev/null || true)"
|
||||||
rm -f /tmp/mock_pid2 /tmp/mock_upstream_pid
|
kill "${MOCK_PID:-}" "${CURRENT_MOCK_PID:-}" "${ROUTER_PID:-}" \
|
||||||
|
"${TTS_PID:-}" "${STT_PID:-}" 2>/dev/null || true
|
||||||
|
rm -f /tmp/mock_pid2 /tmp/mock_upstream_pid /tmp/test-router-state.json \
|
||||||
|
/tmp/fake-profile-fail
|
||||||
|
cp "$FAKE_DIR/profile-fast.conf.disabled" "$FAKE_DIR/override.conf"
|
||||||
wait 2>/dev/null || true
|
wait 2>/dev/null || true
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
@@ -23,8 +32,20 @@ trap cleanup EXIT
|
|||||||
ok() { echo " PASS: $1"; PASS=$((PASS+1)); }
|
ok() { echo " PASS: $1"; PASS=$((PASS+1)); }
|
||||||
bad() { echo " FAIL: $1"; FAIL=$((FAIL+1)); }
|
bad() { echo " FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
|
wait_http() {
|
||||||
|
local url="$1" name="$2"
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
curl -sf "$url" >/dev/null 2>&1 && return 0
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
echo "FEHLER: $name wurde nicht bereit: $url" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# --- Mock-llama.cpp starten (über Fake-systemctl) ------------------------------
|
# --- Mock-llama.cpp starten (über Fake-systemctl) ------------------------------
|
||||||
echo "== Starte Mock-llama.cpp (Port $UP_PORT)"
|
echo "== Starte Mock-llama.cpp (Port $UP_PORT)"
|
||||||
|
cp "$FAKE_DIR/profile-fast.conf.disabled" "$FAKE_DIR/override.conf"
|
||||||
|
rm -f /tmp/test-router-state.json /tmp/mock_upstream_pid
|
||||||
FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid \
|
FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid \
|
||||||
FAKE_SYSTEMD_PORT="$UP_PORT" \
|
FAKE_SYSTEMD_PORT="$UP_PORT" \
|
||||||
FAKE_SYSTEMD_PROFILE_DIR="$FAKE_DIR" \
|
FAKE_SYSTEMD_PROFILE_DIR="$FAKE_DIR" \
|
||||||
@@ -33,11 +54,15 @@ FAKE_SYSTEMD_LOG=/tmp/mock_upstream.log \
|
|||||||
bash dev/fake-systemctl.sh start
|
bash dev/fake-systemctl.sh start
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
MOCK_PID=$(cat /tmp/mock_upstream_pid 2>/dev/null || echo "")
|
MOCK_PID=$(cat /tmp/mock_upstream_pid 2>/dev/null || echo "")
|
||||||
|
wait_http "http://127.0.0.1:$UP_PORT/health" "Mock-llama.cpp" || exit 1
|
||||||
|
|
||||||
# --- Router starten -----------------------------------------------------------
|
# --- Router starten -----------------------------------------------------------
|
||||||
echo "== Starte Router (Port $RT_PORT)"
|
echo "== Starte Router (Port $RT_PORT)"
|
||||||
rm -rf /tmp/test-images
|
rm -rf /tmp/test-images
|
||||||
ROUTER_HOST=127.0.0.1 ROUTER_PORT="$RT_PORT" \
|
ROUTER_HOST=127.0.0.1 ROUTER_PORT="$RT_PORT" \
|
||||||
|
ROUTER_AUTH_MODE=required ROUTER_API_KEY="$TEST_ROUTER_KEY" \
|
||||||
|
ROUTER_PROFILES_FILE= \
|
||||||
|
ROUTER_STATE_FILE=/tmp/test-router-state.json \
|
||||||
UPSTREAM_URL="http://127.0.0.1:$UP_PORT" \
|
UPSTREAM_URL="http://127.0.0.1:$UP_PORT" \
|
||||||
PROFILE_SCRIPT="$PWD/dev/fake-llama-profile.sh" \
|
PROFILE_SCRIPT="$PWD/dev/fake-llama-profile.sh" \
|
||||||
PROFILE_DIR="$FAKE_DIR" \
|
PROFILE_DIR="$FAKE_DIR" \
|
||||||
@@ -58,16 +83,27 @@ TTS_WORKER_URL="http://127.0.0.1:$TTS_PORT" \
|
|||||||
STT_WORKER_URL="http://127.0.0.1:$STT_PORT" \
|
STT_WORKER_URL="http://127.0.0.1:$STT_PORT" \
|
||||||
python3 router/ai_profile_router.py >/tmp/router_test.log 2>&1 &
|
python3 router/ai_profile_router.py >/tmp/router_test.log 2>&1 &
|
||||||
ROUTER_PID=$!
|
ROUTER_PID=$!
|
||||||
sleep 0.5
|
wait_http "$BASE/health" "Router" || {
|
||||||
|
cat /tmp/router_test.log >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
rm -f /tmp/test_worker_requests.jsonl
|
rm -f /tmp/test_worker_requests.jsonl
|
||||||
|
|
||||||
|
echo "== Test 0: Authentifizierung + Health/Readiness"
|
||||||
|
CODE=$(command curl -s -o /tmp/err0.json -w "%{http_code}" "$BASE/status")
|
||||||
|
[ "$CODE" = "401" ] && ok "Status ohne Key → 401" || bad "Status ohne Key: HTTP $CODE"
|
||||||
|
CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/health")
|
||||||
|
[ "$CODE" = "200" ] && ok "öffentliche Liveness → 200" || bad "Liveness: HTTP $CODE"
|
||||||
|
CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/ready")
|
||||||
|
[ "$CODE" = "200" ] && ok "öffentliche Readiness → 200" || bad "Readiness: HTTP $CODE"
|
||||||
|
|
||||||
# --- Mock-TTS-Worker starten ----------------------------------------------------
|
# --- Mock-TTS-Worker starten ----------------------------------------------------
|
||||||
echo "== Starte Mock-TTS-Worker (Port $TTS_PORT)"
|
echo "== Starte Mock-TTS-Worker (Port $TTS_PORT)"
|
||||||
MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \
|
MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \
|
||||||
MOCK_TTS_LOG=/tmp/test_tts_requests.jsonl \
|
MOCK_TTS_LOG=/tmp/test_tts_requests.jsonl \
|
||||||
python3 dev/mock_tts_worker.py >/tmp/mock_tts.log 2>&1 &
|
python3 dev/mock_tts_worker.py >/tmp/mock_tts.log 2>&1 &
|
||||||
TTS_PID=$!
|
TTS_PID=$!
|
||||||
sleep 0.5
|
wait_http "http://127.0.0.1:$TTS_PORT/status" "Mock-TTS" || exit 1
|
||||||
rm -f /tmp/test_tts_requests.jsonl
|
rm -f /tmp/test_tts_requests.jsonl
|
||||||
|
|
||||||
# --- Mock-STT-Worker starten ----------------------------------------------------
|
# --- Mock-STT-Worker starten ----------------------------------------------------
|
||||||
@@ -76,7 +112,7 @@ MOCK_STT_PORT="$STT_PORT" MOCK_STT_DELAY=0.1 \
|
|||||||
MOCK_STT_LOG=/tmp/test_stt_requests.jsonl \
|
MOCK_STT_LOG=/tmp/test_stt_requests.jsonl \
|
||||||
python3 dev/mock_stt_worker.py >/tmp/mock_stt.log 2>&1 &
|
python3 dev/mock_stt_worker.py >/tmp/mock_stt.log 2>&1 &
|
||||||
STT_PID=$!
|
STT_PID=$!
|
||||||
sleep 0.5
|
wait_http "http://127.0.0.1:$STT_PORT/status" "Mock-STT" || exit 1
|
||||||
rm -f /tmp/test_stt_requests.jsonl
|
rm -f /tmp/test_stt_requests.jsonl
|
||||||
|
|
||||||
# --- 1. /v1/models -------------------------------------------------------------
|
# --- 1. /v1/models -------------------------------------------------------------
|
||||||
@@ -115,6 +151,7 @@ import json,sys
|
|||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
assert d["model"].startswith("mock-model-"), d
|
assert d["model"].startswith("mock-model-"), d
|
||||||
assert "Mock-Antwort" in d["choices"][0]["message"]["content"], d
|
assert "Mock-Antwort" in d["choices"][0]["message"]["content"], d
|
||||||
|
assert d.get("mock_authorization") is None, d
|
||||||
' && ok "Request wurde weitergeleitet, Modell ersetzt" || bad "Forwarding"
|
' && ok "Request wurde weitergeleitet, Modell ersetzt" || bad "Forwarding"
|
||||||
|
|
||||||
# --- 4. Streaming ----------------------------------------------------------------
|
# --- 4. Streaming ----------------------------------------------------------------
|
||||||
@@ -176,17 +213,34 @@ d=json.load(sys.stdin)
|
|||||||
assert d["model"]=="mock-model-131072", d
|
assert d["model"]=="mock-model-131072", d
|
||||||
' && ok "qwen-long hat Profil long aktiviert und weitergeleitet" || bad "virtuelles Modell"
|
' && ok "qwen-long hat Profil long aktiviert und weitergeleitet" || bad "virtuelles Modell"
|
||||||
|
|
||||||
# --- 9. Ungültiges Profil ------------------------------------------------------------------
|
# --- 9. Methoden und ungültiges virtuelles Modell -----------------------------------------
|
||||||
echo "== Test 9: Ungültiges Profil"
|
echo "== Test 9: sichere Profilmethoden + ungültiges virtuelles Modell"
|
||||||
CODE=$(curl -s -o /tmp/err9.json -w "%{http_code}" -X POST "$BASE/huge")
|
CODE=$(curl -s -o /tmp/err9.json -w "%{http_code}" "$BASE/long")
|
||||||
cat /tmp/err9.json; echo
|
cat /tmp/err9.json; echo
|
||||||
[ "$CODE" = "400" ] && ok "400 bei unbekanntem Profil (POST /huge)" || bad "erwartet 400, bekam $CODE"
|
[ "$CODE" = "405" ] && ok "GET /long verändert kein Profil" || bad "erwartet 405, bekam $CODE"
|
||||||
|
|
||||||
CODE=$(curl -s -o /tmp/err9b.json -w "%{http_code}" -X POST "$BASE/v1/chat/completions" \
|
CODE=$(curl -s -o /tmp/err9b.json -w "%{http_code}" -X POST "$BASE/v1/chat/completions" \
|
||||||
-H "Content-Type: application/json" -d '{"model":"qwen-huge","messages":[]}')
|
-H "Content-Type: application/json" -d '{"model":"qwen-huge","messages":[]}')
|
||||||
cat /tmp/err9b.json; echo
|
cat /tmp/err9b.json; echo
|
||||||
[ "$CODE" = "400" ] && ok "400 bei unbekanntem virtuellen Modell (qwen-huge)" || bad "erwartet 400, bekam $CODE"
|
[ "$CODE" = "400" ] && ok "400 bei unbekanntem virtuellen Modell (qwen-huge)" || bad "erwartet 400, bekam $CODE"
|
||||||
|
|
||||||
|
# Unbekannte einteilige POST-Pfade gehören dem Upstream, nicht dem Profilrouter.
|
||||||
|
CODE=$(curl -s -o /tmp/err9c.json -w "%{http_code}" -X POST "$BASE/tokenize" \
|
||||||
|
-H "Content-Type: application/json" -d '{"content":"Hallo"}')
|
||||||
|
[ "$CODE" = "404" ] && ok "POST /tokenize wurde transparent weitergeleitet" \
|
||||||
|
|| bad "erwartet Upstream-404, bekam $CODE"
|
||||||
|
|
||||||
|
echo medium >/tmp/fake-profile-fail
|
||||||
|
CODE=$(curl -s -o /tmp/err9d.json -w "%{http_code}" -X POST "$BASE/medium")
|
||||||
|
[ "$CODE" = "503" ] && ok "fehlgeschlagenes Profilskript → 503" \
|
||||||
|
|| bad "Profilskript-Fehler: HTTP $CODE"
|
||||||
|
rm -f /tmp/fake-profile-fail
|
||||||
|
CODE=$(curl -s -o /tmp/chat9e.json -w "%{http_code}" "$BASE/v1/chat/completions" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"qwen-fast","messages":[{"role":"user","content":"noch da?"}]}')
|
||||||
|
[ "$CODE" = "200" ] && ok "vorheriges Profil bleibt nach Skriptfehler verfügbar" \
|
||||||
|
|| bad "Qwen nach Profilskript-Fehler: HTTP $CODE"
|
||||||
|
|
||||||
# --- 10. llama.cpp down -> 502, danach Recovery ---------------------------------------------------
|
# --- 10. llama.cpp down -> 502, danach Recovery ---------------------------------------------------
|
||||||
echo "== Test 10: Upstream down -> 502, danach Recovery"
|
echo "== Test 10: Upstream down -> 502, danach Recovery"
|
||||||
# Profil auf fast setzen (aus Test 8 ist long aktiv)
|
# Profil auf fast setzen (aus Test 8 ist long aktiv)
|
||||||
@@ -195,6 +249,10 @@ curl -sf -X POST "$BASE/fast" >/dev/null
|
|||||||
FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid FAKE_SYSTEMD_PORT="$UP_PORT" \
|
FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid FAKE_SYSTEMD_PORT="$UP_PORT" \
|
||||||
bash dev/fake-systemctl.sh stop
|
bash dev/fake-systemctl.sh stop
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
|
CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/health")
|
||||||
|
[ "$CODE" = "200" ] && ok "Liveness bleibt bei downem Modell 200" || bad "Liveness down: HTTP $CODE"
|
||||||
|
CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/ready")
|
||||||
|
[ "$CODE" = "503" ] && ok "Readiness zeigt downes Modell mit 503" || bad "Readiness down: HTTP $CODE"
|
||||||
CODE=$(curl -s -o /tmp/err10.json -w "%{http_code}" -X POST "$BASE/v1/chat/completions" \
|
CODE=$(curl -s -o /tmp/err10.json -w "%{http_code}" -X POST "$BASE/v1/chat/completions" \
|
||||||
-H "Content-Type: application/json" -d '{"model":"qwen-fast","messages":[]}')
|
-H "Content-Type: application/json" -d '{"model":"qwen-fast","messages":[]}')
|
||||||
cat /tmp/err10.json; echo
|
cat /tmp/err10.json; echo
|
||||||
@@ -641,6 +699,26 @@ wait $STT_PID43
|
|||||||
[ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \
|
||||||
&& ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)"
|
&& ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)"
|
||||||
|
|
||||||
|
# --- 44. Zwei konkurrierende Profilanfragen ------------------------------------------------
|
||||||
|
echo "== Test 44: Profil-Lease verhindert Wechsel während eines Chats"
|
||||||
|
curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"qwen-long","mock_delay":1.0,"messages":[{"role":"user","content":"Lang"}]}' \
|
||||||
|
>/tmp/chat44-long.json &
|
||||||
|
CHAT44_PID=$!
|
||||||
|
sleep 0.2
|
||||||
|
curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"qwen-medium","messages":[{"role":"user","content":"Mittel"}]}' \
|
||||||
|
>/tmp/chat44-medium.json
|
||||||
|
wait "$CHAT44_PID"
|
||||||
|
python3 -c '
|
||||||
|
import json
|
||||||
|
long=json.load(open("/tmp/chat44-long.json"))
|
||||||
|
medium=json.load(open("/tmp/chat44-medium.json"))
|
||||||
|
assert long["mock_ctx"] == 131072, long
|
||||||
|
assert medium["mock_ctx"] == 94208, medium
|
||||||
|
' && ok "konkurrierende Chats behielten jeweils ihr Profil" \
|
||||||
|
|| bad "Profil-Lease bei konkurrierenden Chats"
|
||||||
|
|
||||||
# --- Ergebnis --------------------------------------------------------------------------------------------
|
# --- Ergebnis --------------------------------------------------------------------------------------------
|
||||||
echo
|
echo
|
||||||
echo "== Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen =="
|
echo "== Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen =="
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Unit tests for security, runtime persistence and artifact retention."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "router"))
|
||||||
|
os.environ.setdefault("ROUTER_PROFILES_FILE", "")
|
||||||
|
|
||||||
|
from router_support import ( # noqa: E402
|
||||||
|
AuthPolicy,
|
||||||
|
ConfigurationError,
|
||||||
|
RuntimeStore,
|
||||||
|
enforce_artifact_retention,
|
||||||
|
load_profile_registry,
|
||||||
|
)
|
||||||
|
from ai_profile_router import _normalize_vision_image # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class AuthPolicyTests(unittest.TestCase):
|
||||||
|
def test_required_mode_fails_closed_without_key(self) -> None:
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
"ROUTER_AUTH_MODE": "required",
|
||||||
|
"ROUTER_API_KEY": "",
|
||||||
|
"ROUTER_API_KEY_FILE": "/definitely/missing"}, clear=False):
|
||||||
|
with self.assertRaises(ConfigurationError):
|
||||||
|
AuthPolicy.from_environment()
|
||||||
|
|
||||||
|
def test_bearer_and_x_api_key(self) -> None:
|
||||||
|
key = "k" * 48
|
||||||
|
policy = AuthPolicy("required", key)
|
||||||
|
self.assertTrue(policy.accepts(f"Bearer {key}", None))
|
||||||
|
self.assertTrue(policy.accepts(None, key))
|
||||||
|
self.assertFalse(policy.accepts("Bearer wrong", None))
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeStoreTests(unittest.TestCase):
|
||||||
|
def test_atomic_roundtrip_and_delete(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
store = RuntimeStore(str(Path(temp) / "state.json"))
|
||||||
|
store.save(worker="vision", worker_pid=123, last_profile="fast")
|
||||||
|
self.assertEqual(store.load()["worker_pid"], 123)
|
||||||
|
store.clear_worker("vision")
|
||||||
|
state = store.load()
|
||||||
|
self.assertNotIn("worker", state)
|
||||||
|
self.assertEqual(state["last_profile"], "fast")
|
||||||
|
|
||||||
|
def test_concurrent_updates_are_not_lost(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
store = RuntimeStore(str(Path(temp) / "state.json"))
|
||||||
|
threads = [threading.Thread(target=store.save,
|
||||||
|
kwargs={f"key_{index}": index})
|
||||||
|
for index in range(20)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
state = store.load()
|
||||||
|
for index in range(20):
|
||||||
|
self.assertEqual(state[f"key_{index}"], index)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileRegistryTests(unittest.TestCase):
|
||||||
|
def test_explicit_missing_registry_fails_closed(self) -> None:
|
||||||
|
with self.assertRaises(ConfigurationError):
|
||||||
|
load_profile_registry("/definitely/missing/profiles.json")
|
||||||
|
|
||||||
|
|
||||||
|
class VisionInputTests(unittest.TestCase):
|
||||||
|
def test_small_png_data_url_is_accepted(self) -> None:
|
||||||
|
value = "data:image/png;base64,iVBORw0KGgo="
|
||||||
|
self.assertEqual(_normalize_vision_image(value), value)
|
||||||
|
|
||||||
|
def test_remote_url_is_denied_by_default(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "deaktiviert"):
|
||||||
|
_normalize_vision_image("https://example.com/private.png")
|
||||||
|
|
||||||
|
def test_invalid_base64_is_rejected(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "Base64"):
|
||||||
|
_normalize_vision_image("data:image/png;base64,not!base64")
|
||||||
|
|
||||||
|
|
||||||
|
class RetentionTests(unittest.TestCase):
|
||||||
|
def test_oldest_pairs_are_removed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
for index in range(3):
|
||||||
|
png = root / f"image-{index}.png"
|
||||||
|
png.write_bytes(b"x" * 10)
|
||||||
|
png.with_suffix(".json").write_text("{}", encoding="utf-8")
|
||||||
|
stamp = time.time() - (30 - index)
|
||||||
|
os.utime(png, (stamp, stamp))
|
||||||
|
removed = enforce_artifact_retention(
|
||||||
|
temp, max_files=2, max_bytes=0, max_age_days=0)
|
||||||
|
self.assertEqual(removed, ["image-0.png"])
|
||||||
|
self.assertFalse((root / "image-0.json").exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+27
-1
@@ -11,7 +11,7 @@ in diesem Repository oder im Modellmanifest beschrieben sind.
|
|||||||
|
|
||||||
| Komponente | Port | Ausführung | Aufgabe |
|
| Komponente | Port | Ausführung | Aufgabe |
|
||||||
|---|---:|---|---|
|
|---|---:|---|---|
|
||||||
| AI Profile Router | 8081 | systemd, unprivilegiert empfohlen | zentrale Client-API und Orchestrierung |
|
| AI Profile Router | 8081 | systemd, gehärtet | zentrale Client-API und Orchestrierung |
|
||||||
| llama.cpp | 8080 | systemd | Textmodell, Tool Calling und MCP |
|
| llama.cpp | 8080 | systemd | Textmodell, Tool Calling und MCP |
|
||||||
| Whisper | 8084, nur localhost | systemd | Speech-to-Text |
|
| Whisper | 8084, nur localhost | systemd | Speech-to-Text |
|
||||||
| XTTS | 8085, nur localhost | systemd | Text-to-Speech |
|
| XTTS | 8085, nur localhost | systemd | Text-to-Speech |
|
||||||
@@ -29,11 +29,20 @@ in diesem Repository oder im Modellmanifest beschrieben sind.
|
|||||||
4. Der Router wartet auf Modellname und erwartete Kontextgröße.
|
4. Der Router wartet auf Modellname und erwartete Kontextgröße.
|
||||||
5. Erst dann wird die Anfrage an Port 8080 weitergeleitet.
|
5. Erst dann wird die Anfrage an Port 8080 weitergeleitet.
|
||||||
|
|
||||||
|
Profilwahl und Weiterleitung bilden dabei eine atomare Modell-Lease. Ein
|
||||||
|
zweiter Request kann das Profil nicht mehr zwischen Auswahl und Inferenz
|
||||||
|
wechseln. Laufende Requests werden vor einem GPU-Hotswap vollständig beendet;
|
||||||
|
bei Überschreiten des Drain-Timeouts wird der Wechsel abgebrochen, nicht der
|
||||||
|
Chat.
|
||||||
|
|
||||||
## Profilprinzip
|
## Profilprinzip
|
||||||
|
|
||||||
Die Profile sind vollständige systemd-Overrides. Ein Profilwechsel kopiert die
|
Die Profile sind vollständige systemd-Overrides. Ein Profilwechsel kopiert die
|
||||||
gewählte Datei atomar auf `override.conf`, lädt systemd neu und startet genau
|
gewählte Datei atomar auf `override.conf`, lädt systemd neu und startet genau
|
||||||
einen llama.cpp-Dienst neu. Es gibt niemals mehrere Textmodelle gleichzeitig.
|
einen llama.cpp-Dienst neu. Es gibt niemals mehrere Textmodelle gleichzeitig.
|
||||||
|
Das unabhängige Register `/etc/mike-ai/router-profiles.json` definiert Kontext
|
||||||
|
und erwarteten Modellalias. Readiness gilt nur, wenn beides exakt passt; eine
|
||||||
|
abweichende oder fehlende Registry verhindert den Start.
|
||||||
|
|
||||||
## GPU-Hotswap
|
## GPU-Hotswap
|
||||||
|
|
||||||
@@ -47,6 +56,23 @@ Vision und Bildgenerierung teilen sich die RTX mit dem Textmodell. Der Router:
|
|||||||
6. stellt das ursprüngliche Textprofil wieder her,
|
6. stellt das ursprüngliche Textprofil wieder her,
|
||||||
7. prüft Modell und Kontext vor der Freigabe.
|
7. prüft Modell und Kontext vor der Freigabe.
|
||||||
|
|
||||||
|
Der zuletzt stabile Zustand und temporäre Worker-PIDs werden atomar unter
|
||||||
|
`/var/lib/mike-ai-profile-router/state.json` festgehalten. Beim Routerstart
|
||||||
|
werden ausschließlich dort erfasste Prozesse nach zusätzlicher
|
||||||
|
Kommandozeilenprüfung beendet und das letzte Profil wiederhergestellt.
|
||||||
|
|
||||||
|
## Vertrauensgrenzen
|
||||||
|
|
||||||
|
- Port 8081 verlangt einen eigenen API-Key; nur `/health` und `/ready` sind
|
||||||
|
absichtlich anonym und enthalten keine privaten Daten.
|
||||||
|
- Der Client-Key wird vor dem lokalen Upstream entfernt.
|
||||||
|
- Bild-Uploads sind begrenzt. Remote-Bild-URLs sind standardmäßig aus, damit
|
||||||
|
der Vision-Pfad nicht als Zugriff auf Intranet oder Metadatenendpunkte dient.
|
||||||
|
- Maximal 16 Requests werden gleichzeitig bearbeitet; weitere erhalten 429.
|
||||||
|
- Der Dienst benötigt derzeit wegen systemd-Profilwechsel und GPU-Hotswap noch
|
||||||
|
Root-Rechte. Die systemd-Sandbox begrenzt diese, ersetzt aber keine künftige
|
||||||
|
Aufteilung in unprivilegierten Proxy und eng begrenzten Root-Helper.
|
||||||
|
|
||||||
## Verzeichnislayout auf dem Zielhost
|
## Verzeichnislayout auf dem Zielhost
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
|
|||||||
|
|
||||||
## Phase C – Router
|
## Phase C – Router
|
||||||
|
|
||||||
|
- [ ] Start ohne API-Key schlägt bewusst fehl
|
||||||
|
- [ ] `/health` bleibt bei Hotswap 200 und `/ready` wird vorübergehend 503
|
||||||
|
- [ ] geschützte Endpunkte liefern ohne Key 401
|
||||||
|
- [ ] Router-Key erscheint weder im Upstream noch im Journal
|
||||||
- [ ] `/status` meldet den richtigen Upstream
|
- [ ] `/status` meldet den richtigen Upstream
|
||||||
- [ ] `/v1/models` liefert drei virtuelle Modelle
|
- [ ] `/v1/models` liefert drei virtuelle Modelle
|
||||||
- [ ] `/fast`, `/medium` und `/long` wechseln zuverlässig
|
- [ ] `/fast`, `/medium` und `/long` wechseln zuverlässig
|
||||||
@@ -40,6 +44,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
|
|||||||
- [ ] Tool Calls funktionieren
|
- [ ] Tool Calls funktionieren
|
||||||
- [ ] Fehler sind OpenAI-kompatibel
|
- [ ] Fehler sind OpenAI-kompatibel
|
||||||
- [ ] ein abgebrochener Client hinterlässt keinen blockierten Job
|
- [ ] ein abgebrochener Client hinterlässt keinen blockierten Job
|
||||||
|
- [ ] erzwungener Routerabbruch wird aus Zustandsdatei sauber rekonstruiert
|
||||||
|
- [ ] fehlende/falsche Profilregistry verhindert falsche Readiness
|
||||||
|
|
||||||
## Phase D – Web und MCP
|
## Phase D – Web und MCP
|
||||||
|
|
||||||
@@ -58,6 +64,7 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
|
|||||||
- [ ] neues Bild löst genau einen Vision-Hotswap aus
|
- [ ] neues Bild löst genau einen Vision-Hotswap aus
|
||||||
- [ ] Folgefrage verwendet Cache und keinen zweiten Hotswap
|
- [ ] Folgefrage verwendet Cache und keinen zweiten Hotswap
|
||||||
- [ ] Bilddaten werden vor dem Textmodell sanitisiert
|
- [ ] Bilddaten werden vor dem Textmodell sanitisiert
|
||||||
|
- [ ] Remote-/private Bild-URL wird abgewiesen und übergroße Data-URL blockiert
|
||||||
- [ ] Qwen-Profil wird nach Vision wiederhergestellt
|
- [ ] Qwen-Profil wird nach Vision wiederhergestellt
|
||||||
- [ ] FLUX erzeugt Standard- und High-Bild
|
- [ ] FLUX erzeugt Standard- und High-Bild
|
||||||
- [ ] Qwen-Profil wird nach FLUX wiederhergestellt
|
- [ ] Qwen-Profil wird nach FLUX wiederhergestellt
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ Das bestehende `deploy/install.sh` installiert Router, Vision/Bild-Worker,
|
|||||||
Whisper und XTTS. Vor produktiver Verwendung müssen Modellpfade in der
|
Whisper und XTTS. Vor produktiver Verwendung müssen Modellpfade in der
|
||||||
systemd-Datei gegen das lokale Manifest geprüft werden.
|
systemd-Datei gegen das lokale Manifest geprüft werden.
|
||||||
|
|
||||||
|
Der Installer erzeugt `/etc/mike-ai/router-api-key` (0600), installiert das
|
||||||
|
Profilregister und legt die atomare Zustandsablage an. Danach wird der Key
|
||||||
|
einmal manuell in die Secret-Stores der erlaubten Clients übernommen. Er darf
|
||||||
|
nicht im Terminal-Log, in Screenshots oder in Git dokumentiert werden.
|
||||||
|
|
||||||
|
Der Router läuft aktuell als gehärteter Root-Dienst, weil er den
|
||||||
|
llama.cpp-Systemdienst und temporäre GPU-Worker kontrolliert. Das ist eine
|
||||||
|
bewusste Restabweichung. Ein späterer V3-Schritt soll den HTTP-Proxy als eigenen
|
||||||
|
Benutzer ausführen und nur Profil-/Hotswap-Befehle an einen fest
|
||||||
|
parametrisierten Root-Helper delegieren.
|
||||||
|
|
||||||
## 8. Websuche
|
## 8. Websuche
|
||||||
|
|
||||||
TinySearch und SearXNG bleiben als einziges Docker-Teilsystem isoliert. Die
|
TinySearch und SearXNG bleiben als einziges Docker-Teilsystem isoliert. Die
|
||||||
|
|||||||
+24
-1
@@ -20,12 +20,19 @@ Clients verbinden sich mit:
|
|||||||
http://HOST:8081/v1
|
http://HOST:8081/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Als API-Key verwenden sie den Inhalt von `/etc/mike-ai/router-api-key` über
|
||||||
|
Bearer-Authentifizierung. Der Key gehört in den Secret-Store des Clients,
|
||||||
|
nicht in Chat, Repository oder URL. Eine Rotation erfolgt atomar durch
|
||||||
|
Ersetzen der Datei und Neustart des Routerdienstes.
|
||||||
|
|
||||||
Sie sollen nicht direkt Port 8080 verwenden, weil sie sonst Profilumschaltung,
|
Sie sollen nicht direkt Port 8080 verwenden, weil sie sonst Profilumschaltung,
|
||||||
Vision, Bildgenerierung, STT und TTS umgehen.
|
Vision, Bildgenerierung, STT und TTS umgehen.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- `GET /status`: Router, Profil, Upstream, aktive Jobs
|
- `GET /health`: Routerprozess lebt; bleibt bei geplantem Hotswap grün
|
||||||
|
- `GET /ready`: Router und Textmodell sind einsatzbereit
|
||||||
|
- `GET /status`: authentifizierter Detailstatus, Profil, Upstream, aktive Jobs
|
||||||
- `GET /v1/models`: virtuelle Modelle
|
- `GET /v1/models`: virtuelle Modelle
|
||||||
- llama.cpp-Metriken: Port 8080, nur im administrativen Netz freigeben
|
- llama.cpp-Metriken: Port 8080, nur im administrativen Netz freigeben
|
||||||
- systemd-Journal: nur Metadaten und Fehler prüfen; keine Promptinhalte sammeln
|
- systemd-Journal: nur Metadaten und Fehler prüfen; keine Promptinhalte sammeln
|
||||||
@@ -35,6 +42,22 @@ Vision, Bildgenerierung, STT und TTS umgehen.
|
|||||||
Niemals Build, Quantisierung und Profil gleichzeitig ändern. Immer genau eine
|
Niemals Build, Quantisierung und Profil gleichzeitig ändern. Immer genau eine
|
||||||
Variable ändern und anschließend denselben Benchmark ausführen.
|
Variable ändern und anschließend denselben Benchmark ausführen.
|
||||||
|
|
||||||
|
Profilkontext und Alias werden zusätzlich in
|
||||||
|
`/etc/mike-ai/router-profiles.json` gepflegt. Änderungen an Override und
|
||||||
|
Registry gehören in denselben getesteten Commit; andernfalls verweigert die
|
||||||
|
Readiness bewusst die Freigabe.
|
||||||
|
|
||||||
|
## Fehler- und Recovery-Verhalten
|
||||||
|
|
||||||
|
- Ein Profilwechsel bricht ab, wenn laufende Chats nicht innerhalb des
|
||||||
|
Drain-Timeouts enden. Er beendet niemals absichtlich einen Chat.
|
||||||
|
- Nach einem Routerabsturz wird das letzte stabile Profil aus der atomaren
|
||||||
|
Zustandsdatei rekonstruiert.
|
||||||
|
- `/health = 200`, aber `/ready = 503` bedeutet: Router lebt, Modell ist noch
|
||||||
|
nicht bereit oder wird gerade gewechselt.
|
||||||
|
- `429` bedeutet, dass die Parallelitätsgrenze erreicht ist; der Client soll
|
||||||
|
mit Backoff erneut versuchen.
|
||||||
|
|
||||||
## Kapazitätsregeln
|
## Kapazitätsregeln
|
||||||
|
|
||||||
- Systempartition dauerhaft unter 85 Prozent halten.
|
- Systempartition dauerhaft unter 85 Prozent halten.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Router V2 – Migration und Kompatibilität
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
V2 behält die OpenAI-kompatible Basis-URL und die virtuellen Modelle
|
||||||
|
`qwen-fast`, `qwen-medium` und `qwen-long`. Bestehende Chat-, Tool-, Audio-,
|
||||||
|
Vision- und Bildpfade bleiben erhalten. Die Änderungen betreffen absichtlich
|
||||||
|
die Stellen, an denen der alte Router unsicher oder nicht deterministisch war.
|
||||||
|
|
||||||
|
## Bewusste Änderungen
|
||||||
|
|
||||||
|
| Alt | V2 |
|
||||||
|
|---|---|
|
||||||
|
| alle LAN-Clients ohne Authentifizierung | API-Key für alle fachlichen Endpunkte |
|
||||||
|
| `GET /fast` schaltet ein Modell | nur noch `POST /fast` (analog medium/long) |
|
||||||
|
| `/health` hängt am Modellzustand | `/health` = Prozess, `/ready` = Textmodell |
|
||||||
|
| Profil durch Textvergleich erkannt | Kontext + Alias aus Profilregister geprüft |
|
||||||
|
| Profilwechsel und Request konnten sich überholen | atomare Modell-Lease |
|
||||||
|
| Drain-Timeout beendete trotzdem das Modell | Wechsel wird sicher abgebrochen |
|
||||||
|
| Workerzustand nur im RAM | atomare Zustandsdatei und Startup-Recovery |
|
||||||
|
| beliebige Bild-URL | Data-URL, Größenlimit; Remote standardmäßig aus |
|
||||||
|
| unbegrenzte Parallelität und Bildablage | Request- und Retention-Limits |
|
||||||
|
| Auth-Header potenziell am Upstream | Router-Credentials werden entfernt |
|
||||||
|
|
||||||
|
## Client-Migration
|
||||||
|
|
||||||
|
1. Router-Key aus `/etc/mike-ai/router-api-key` ohne Anzeige in einen lokalen
|
||||||
|
Secret-Store des Clients übernehmen.
|
||||||
|
2. Basis-URL unverändert auf `http://HOST:8081/v1` lassen.
|
||||||
|
3. Den Key als OpenAI-API-Key/Bearer-Token konfigurieren.
|
||||||
|
4. `GET /v1/models` testen und anschließend einen kurzen Chat über
|
||||||
|
`qwen-fast` senden.
|
||||||
|
5. Automationen, die Profile per GET schalten, auf POST umstellen.
|
||||||
|
6. Überwachung auf `/health` (Liveness) und `/ready` (Readiness) aufteilen.
|
||||||
|
|
||||||
|
## Sicheres Rollout
|
||||||
|
|
||||||
|
V2 wird nicht blind über einen laufenden Router kopiert:
|
||||||
|
|
||||||
|
1. Repository-Commit und aktuelle produktive Konfiguration sichern.
|
||||||
|
2. Profilregister gegen alle drei systemd-Overrides prüfen.
|
||||||
|
3. API-Key erzeugen und Clients vorbereiten.
|
||||||
|
4. Router installieren und zuerst lokal mit Key prüfen.
|
||||||
|
5. Fast, Medium und Long jeweils einmal schalten und Alias/Kontext prüfen.
|
||||||
|
6. Streaming und einen Tool Call testen.
|
||||||
|
7. Erst danach normale Clients auf V2 freigeben.
|
||||||
|
|
||||||
|
Ein Rollback stellt Routerdateien und Unit aus dem Installationsbackup wieder
|
||||||
|
her. Der neu erzeugte API-Key und die Zustandsdatei enthalten keine
|
||||||
|
Modelldateien oder Chatdaten.
|
||||||
|
|
||||||
|
## Verbleibender Architekturpunkt
|
||||||
|
|
||||||
|
Der Routerprozess läuft derzeit als root, weil er den systemweiten llama.cpp-
|
||||||
|
Dienst und temporäre GPU-Worker steuert. Die Unit ist stark gehärtet, dennoch
|
||||||
|
ist das nicht das langfristige Ideal. V3 soll HTTP/API und privilegierte
|
||||||
|
Orchestrierung trennen: unprivilegierter Proxy plus kleiner Root-Helper mit
|
||||||
|
festen, nicht frei parametrisierbaren Aktionen.
|
||||||
@@ -42,6 +42,20 @@ Empfohlene Trennung:
|
|||||||
- Firewall erlaubt nur bekannte Quellnetze.
|
- Firewall erlaubt nur bekannte Quellnetze.
|
||||||
- Externe Suche erhält nur die tatsächliche Suchanfrage, keine Chat-Historie.
|
- Externe Suche erhält nur die tatsächliche Suchanfrage, keine Chat-Historie.
|
||||||
|
|
||||||
|
## Router-Grenze
|
||||||
|
|
||||||
|
- Alle fachlichen Endpunkte verlangen einen mindestens 32 Zeichen langen,
|
||||||
|
zufälligen Router-Key. Der Dienst startet ohne gültigen Key nicht.
|
||||||
|
- `/health` und `/ready` sind die einzigen anonymen Endpunkte und geben nur
|
||||||
|
groben Betriebszustand aus.
|
||||||
|
- Authentifizierungsheader werden niemals an llama.cpp weitergereicht.
|
||||||
|
- Remote-Bild-URLs sind standardmäßig gesperrt. Data-URLs werden auf MIME-Typ,
|
||||||
|
Base64-Gültigkeit und 20 MiB Maximalgröße geprüft.
|
||||||
|
- Die Zahl gleichzeitiger Requests ist begrenzt; große Uploads sind global
|
||||||
|
begrenzt und generierte Bilder werden nach Alter, Anzahl und Größe bereinigt.
|
||||||
|
- Crash-Recovery beendet keine PID nur aufgrund einer Zahl, sondern verlangt
|
||||||
|
zusätzlich einen erwarteten Prozessmarker in `/proc/<pid>/cmdline`.
|
||||||
|
|
||||||
## Schreibaktionen
|
## Schreibaktionen
|
||||||
|
|
||||||
Jede destruktive oder persistente Aktion verwendet:
|
Jede destruktive oder persistente Aktion verwendet:
|
||||||
|
|||||||
+466
-106
@@ -54,24 +54,39 @@ Nur Python-Standardbibliothek. Logging nach stdout (journald).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import binascii
|
||||||
import email
|
import email
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import http.client
|
import http.client
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from email.policy import compat32
|
from email.policy import compat32
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from router_support import (
|
||||||
|
AuthPolicy,
|
||||||
|
ConfigurationError,
|
||||||
|
RuntimeStore,
|
||||||
|
enforce_artifact_retention,
|
||||||
|
load_profile_registry,
|
||||||
|
terminate_recorded_worker,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Konfiguration (über Umgebungsvariablen, vgl. systemd-Unit)
|
# Konfiguration (über Umgebungsvariablen, vgl. systemd-Unit)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -102,6 +117,10 @@ IMAGE_WORKER_LOG = os.environ.get(
|
|||||||
IMAGE_START_TIMEOUT = float(os.environ.get("IMAGE_START_TIMEOUT", "120")) # s, Worker-Start
|
IMAGE_START_TIMEOUT = float(os.environ.get("IMAGE_START_TIMEOUT", "120")) # s, Worker-Start
|
||||||
IMAGE_GEN_TIMEOUT = float(os.environ.get("IMAGE_GEN_TIMEOUT", "1800")) # s, pro Bild
|
IMAGE_GEN_TIMEOUT = float(os.environ.get("IMAGE_GEN_TIMEOUT", "1800")) # s, pro Bild
|
||||||
IMAGE_VRAM_FREE_TIMEOUT = float(os.environ.get("IMAGE_VRAM_FREE_TIMEOUT", "90")) # s, VRAM-Abgabe
|
IMAGE_VRAM_FREE_TIMEOUT = float(os.environ.get("IMAGE_VRAM_FREE_TIMEOUT", "90")) # s, VRAM-Abgabe
|
||||||
|
IMAGE_RETENTION_FILES = int(os.environ.get("IMAGE_RETENTION_FILES", "100"))
|
||||||
|
IMAGE_RETENTION_BYTES = int(os.environ.get(
|
||||||
|
"IMAGE_RETENTION_BYTES", str(5 * 1024 * 1024 * 1024)))
|
||||||
|
IMAGE_RETENTION_DAYS = int(os.environ.get("IMAGE_RETENTION_DAYS", "30"))
|
||||||
|
|
||||||
# --- Vision-Orchestrierung (Q3 "Augen", temporär) ---
|
# --- Vision-Orchestrierung (Q3 "Augen", temporär) ---
|
||||||
# Chat-Requests mit Bild im letzten User-Message lösen einen
|
# Chat-Requests mit Bild im letzten User-Message lösen einen
|
||||||
@@ -121,6 +140,10 @@ VISION_INFER_TIMEOUT = float(os.environ.get("VISION_INFER_TIMEOUT", "300")) # s
|
|||||||
VISION_UNLOAD_TIMEOUT = float(os.environ.get("VISION_UNLOAD_TIMEOUT", "120")) # s
|
VISION_UNLOAD_TIMEOUT = float(os.environ.get("VISION_UNLOAD_TIMEOUT", "120")) # s
|
||||||
VISION_MAX_TOKENS = int(os.environ.get("VISION_MAX_TOKENS", "4096"))
|
VISION_MAX_TOKENS = int(os.environ.get("VISION_MAX_TOKENS", "4096"))
|
||||||
VISION_CACHE_MAX = int(os.environ.get("VISION_CACHE_MAX", "64"))
|
VISION_CACHE_MAX = int(os.environ.get("VISION_CACHE_MAX", "64"))
|
||||||
|
VISION_MAX_IMAGE_BYTES = int(os.environ.get(
|
||||||
|
"VISION_MAX_IMAGE_BYTES", str(20 * 1024 * 1024)))
|
||||||
|
VISION_ALLOW_REMOTE_URLS = os.environ.get(
|
||||||
|
"VISION_ALLOW_REMOTE_URLS", "false").lower() in {"1", "true", "yes"}
|
||||||
VISION_LOG = os.environ.get(
|
VISION_LOG = os.environ.get(
|
||||||
"VISION_LOG", "/opt/mike-ai/ai-profile-router/vision_server.log")
|
"VISION_LOG", "/opt/mike-ai/ai-profile-router/vision_server.log")
|
||||||
|
|
||||||
@@ -165,16 +188,32 @@ MAX_UPLOAD_SIZE = int(os.environ.get("MAX_UPLOAD_SIZE", 50 * 1024 * 1024))
|
|||||||
CHAT_WAIT_TIMEOUT = float(os.environ.get("CHAT_WAIT_TIMEOUT", "300")) # s, max. Warten
|
CHAT_WAIT_TIMEOUT = float(os.environ.get("CHAT_WAIT_TIMEOUT", "300")) # s, max. Warten
|
||||||
CHAT_DRAIN_TIMEOUT = float(os.environ.get("CHAT_DRAIN_TIMEOUT", "60")) # s, max. Warten auf aktive Chats
|
CHAT_DRAIN_TIMEOUT = float(os.environ.get("CHAT_DRAIN_TIMEOUT", "60")) # s, max. Warten auf aktive Chats
|
||||||
|
|
||||||
PROFILES = {"fast": 73728, "medium": 94208, "long": 131072}
|
RUNTIME_STATE_FILE = os.environ.get(
|
||||||
|
"ROUTER_STATE_FILE", "/var/lib/mike-ai-profile-router/state.json")
|
||||||
|
PROFILE_REGISTRY_FILE = os.environ.get(
|
||||||
|
"ROUTER_PROFILES_FILE", "/etc/mike-ai/router-profiles.json")
|
||||||
|
ALLOW_LEGACY_GET_SWITCH = os.environ.get(
|
||||||
|
"ALLOW_LEGACY_GET_SWITCH", "false").lower() in {"1", "true", "yes"}
|
||||||
|
MAX_CONCURRENT_REQUESTS = int(os.environ.get(
|
||||||
|
"ROUTER_MAX_CONCURRENT_REQUESTS", "16"))
|
||||||
|
|
||||||
|
PROFILE_REGISTRY = load_profile_registry(PROFILE_REGISTRY_FILE)
|
||||||
|
PROFILES = {name: definition["context"]
|
||||||
|
for name, definition in PROFILE_REGISTRY.items()}
|
||||||
|
EXPECTED_MODELS = {name: definition.get("model_alias")
|
||||||
|
for name, definition in PROFILE_REGISTRY.items()}
|
||||||
VIRTUAL_MODELS = {f"qwen-{name}": name for name in PROFILES}
|
VIRTUAL_MODELS = {f"qwen-{name}": name for name in PROFILES}
|
||||||
|
|
||||||
log = logging.getLogger("ai-profile-router")
|
log = logging.getLogger("ai-profile-router")
|
||||||
|
AUTH: AuthPolicy | None = None
|
||||||
|
RUNTIME = RuntimeStore(RUNTIME_STATE_FILE)
|
||||||
|
REQUEST_SLOTS = threading.BoundedSemaphore(max(1, MAX_CONCURRENT_REQUESTS))
|
||||||
|
|
||||||
# Hop-by-hop-Header, die nicht an Upstream/Client weitergereicht werden.
|
# Hop-by-hop-Header, die nicht an Upstream/Client weitergereicht werden.
|
||||||
HOP_BY_HOP = {
|
HOP_BY_HOP = {
|
||||||
"host", "connection", "keep-alive", "proxy-authenticate",
|
"host", "connection", "keep-alive", "proxy-authenticate",
|
||||||
"proxy-authorization", "te", "trailer", "transfer-encoding",
|
"proxy-authorization", "te", "trailer", "transfer-encoding",
|
||||||
"upgrade", "content-length",
|
"upgrade", "content-length", "authorization", "x-api-key",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -237,15 +276,17 @@ class _State:
|
|||||||
gegenseitiger Ausschluss, kein Race zwischen beiden.
|
gegenseitiger Ausschluss, kein Race zwischen beiden.
|
||||||
avail_lock : schützt qwen_unavailable + active_chats (Chat-Waiting).
|
avail_lock : schützt qwen_unavailable + active_chats (Chat-Waiting).
|
||||||
"""
|
"""
|
||||||
lock = threading.Lock() # GPU-/Model-Lock (Profilwechsel + Image + Vision)
|
def __init__(self) -> None:
|
||||||
switching: str | None = None # Profil, das gerade gewechselt wird
|
# RLock erlaubt atomare Abläufe aus Profilwahl + Vision + Chat-Lease,
|
||||||
started = time.time()
|
# während die darunterliegenden Funktionen denselben Lock verwenden.
|
||||||
image = _ImageState()
|
self.lock = threading.RLock()
|
||||||
vision = _VisionState()
|
self.switching: str | None = None
|
||||||
# Qwen-Verfügbarkeit für das Chat-Waiting:
|
self.started = time.time()
|
||||||
qwen_unavailable = False # True, wenn Qwen down/neu geladen wird
|
self.image = _ImageState()
|
||||||
active_chats = 0 # Anzahl laufender Chat-Requests
|
self.vision = _VisionState()
|
||||||
avail_lock = threading.Lock() # schützt die beiden Felder oben
|
self.qwen_unavailable = True
|
||||||
|
self.active_chats = 0
|
||||||
|
self.avail_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
STATE = _State()
|
STATE = _State()
|
||||||
@@ -265,9 +306,9 @@ def _wait_chats_drained(timeout: float | None = None) -> None:
|
|||||||
return
|
return
|
||||||
n = STATE.active_chats
|
n = STATE.active_chats
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
log.warning("Chat-Drain-Timeout nach %.0f s (%d aktive Chats) – "
|
raise RuntimeError(
|
||||||
"fahre trotzdem fort", timeout, n)
|
f"Profil-/GPU-Wechsel nach {timeout:.0f} s abgebrochen: "
|
||||||
return
|
f"noch {n} aktive Chat-Anfrage(n)")
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
@@ -426,17 +467,24 @@ def _read(path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def current_profile() -> str | None:
|
def current_profile() -> str | None:
|
||||||
"""Aktives Profil, ermittelt durch Vergleich der override.conf."""
|
"""Aktives Profil anhand semantischer Werte der override.conf.
|
||||||
|
|
||||||
|
Kommentare, Leerraum oder die Reihenfolge anderer llama.cpp-Optionen
|
||||||
|
beeinflussen die Erkennung nicht mehr.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
override = _read(os.path.join(PROFILE_DIR, "override.conf"))
|
override = _read(os.path.join(PROFILE_DIR, "override.conf"))
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
for name in PROFILES:
|
ctx_match = re.search(r"(?:^|\s)--ctx-size\s+(\d+)(?:\s|$)", override)
|
||||||
try:
|
alias_match = re.search(r"(?:^|\s)--alias\s+([^\s]+)", override)
|
||||||
ref = _read(os.path.join(PROFILE_DIR, f"profile-{name}.conf.disabled"))
|
if not ctx_match:
|
||||||
except OSError:
|
return None
|
||||||
continue
|
ctx = int(ctx_match.group(1))
|
||||||
if override == ref:
|
alias = alias_match.group(1) if alias_match else None
|
||||||
|
for name, expected_ctx in PROFILES.items():
|
||||||
|
expected_alias = EXPECTED_MODELS.get(name)
|
||||||
|
if ctx == expected_ctx and (not expected_alias or alias == expected_alias):
|
||||||
return name
|
return name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -444,20 +492,33 @@ def current_profile() -> str | None:
|
|||||||
def _wait_ready(profile: str, deadline: float) -> None:
|
def _wait_ready(profile: str, deadline: float) -> None:
|
||||||
"""Wartet, bis llama.cpp das Profil geladen hat (Modell + ctx)."""
|
"""Wartet, bis llama.cpp das Profil geladen hat (Modell + ctx)."""
|
||||||
expected_ctx = PROFILES[profile]
|
expected_ctx = PROFILES[profile]
|
||||||
|
expected_model = EXPECTED_MODELS.get(profile)
|
||||||
while True:
|
while True:
|
||||||
status = upstream_status()
|
status = upstream_status()
|
||||||
if (status["reachable"] and status.get("model")
|
if (status["reachable"] and status.get("model")
|
||||||
and status.get("ctx") == expected_ctx):
|
and status.get("ctx") == expected_ctx
|
||||||
|
and (not expected_model or status.get("model") == expected_model)):
|
||||||
log.info("llama.cpp bereit: Profil=%s Modell=%s ctx=%s",
|
log.info("llama.cpp bereit: Profil=%s Modell=%s ctx=%s",
|
||||||
profile, status.get("model"), status.get("ctx"))
|
profile, status.get("model"), status.get("ctx"))
|
||||||
return
|
return
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"llama.cpp nach {SWITCH_TIMEOUT:.0f} s nicht bereit "
|
f"llama.cpp nach {SWITCH_TIMEOUT:.0f} s nicht bereit "
|
||||||
f"(erwartet ctx {expected_ctx}, aktuell: {status.get('ctx')})")
|
f"(erwartet Modell {expected_model or '*'} / ctx "
|
||||||
|
f"{expected_ctx}, aktuell: {status.get('model')} / "
|
||||||
|
f"{status.get('ctx')})")
|
||||||
time.sleep(POLL_INTERVAL)
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_is_ready(profile: str, status: dict | None = None) -> bool:
|
||||||
|
status = status or upstream_status()
|
||||||
|
expected_model = EXPECTED_MODELS.get(profile)
|
||||||
|
return bool(status.get("reachable") and status.get("model")
|
||||||
|
and status.get("ctx") == PROFILES[profile]
|
||||||
|
and (not expected_model
|
||||||
|
or status.get("model") == expected_model))
|
||||||
|
|
||||||
|
|
||||||
def switch_profile(profile: str, implicit: bool = False) -> None:
|
def switch_profile(profile: str, implicit: bool = False) -> None:
|
||||||
"""Stellt sicher, dass das Profil aktiv ist, und wartet bis es geladen ist.
|
"""Stellt sicher, dass das Profil aktiv ist, und wartet bis es geladen ist.
|
||||||
|
|
||||||
@@ -479,8 +540,7 @@ def switch_profile(profile: str, implicit: bool = False) -> None:
|
|||||||
try:
|
try:
|
||||||
cur = current_profile()
|
cur = current_profile()
|
||||||
up = upstream_status()
|
up = upstream_status()
|
||||||
ready = (up["reachable"] and up.get("model")
|
ready = _profile_is_ready(profile, up)
|
||||||
and up.get("ctx") == PROFILES[profile])
|
|
||||||
if cur == profile and ready:
|
if cur == profile and ready:
|
||||||
log.info("Profil %s ist bereits aktiv", profile)
|
log.info("Profil %s ist bereits aktiv", profile)
|
||||||
return
|
return
|
||||||
@@ -510,19 +570,26 @@ def switch_profile(profile: str, implicit: bool = False) -> None:
|
|||||||
if out:
|
if out:
|
||||||
log.info("llama-profile: %s", out[-500:])
|
log.info("llama-profile: %s", out[-500:])
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
# whiptail bricht das Skript ohne TTY ab – der Wechsel
|
raise RuntimeError(
|
||||||
# selbst (cp + systemctl restart) ist dann erledigt.
|
f"llama-profile fehlgeschlagen (Exit-Code "
|
||||||
log.warning("llama-profile Exit-Code %d (ohne TTY "
|
f"{proc.returncode}): {out[-500:]}")
|
||||||
"erwartet)", proc.returncode)
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
log.error("llama-profile hat 120 s überschritten")
|
raise RuntimeError("llama-profile hat 120 s überschritten")
|
||||||
if current_profile() != profile:
|
if current_profile() != profile:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Profildatei wurde nicht gesetzt (erwartet: {profile})")
|
f"Profildatei wurde nicht gesetzt (erwartet: {profile})")
|
||||||
log.info("Warte, bis llama.cpp das Profil geladen hat ...")
|
log.info("Warte, bis llama.cpp das Profil geladen hat ...")
|
||||||
_wait_ready(profile, time.monotonic() + SWITCH_TIMEOUT)
|
_wait_ready(profile, time.monotonic() + SWITCH_TIMEOUT)
|
||||||
|
RUNTIME.save(last_profile=profile, phase="idle")
|
||||||
finally:
|
finally:
|
||||||
_set_qwen_unavailable(False)
|
# Nach einem fehlgeschlagenen Skript/Timeout darf der Router
|
||||||
|
# Qwen nicht blind freigeben. Nur ein semantisch verifiziertes
|
||||||
|
# Profil (Alias + Kontext) wird wieder als verfügbar markiert.
|
||||||
|
active = current_profile()
|
||||||
|
up_after = upstream_status()
|
||||||
|
available = bool(active in PROFILES
|
||||||
|
and _profile_is_ready(active, up_after))
|
||||||
|
_set_qwen_unavailable(not available)
|
||||||
finally:
|
finally:
|
||||||
STATE.switching = None
|
STATE.switching = None
|
||||||
|
|
||||||
@@ -539,6 +606,7 @@ class _Worker:
|
|||||||
self.model_loaded = False
|
self.model_loaded = False
|
||||||
self._queue: queue.Queue[dict] = queue.Queue()
|
self._queue: queue.Queue[dict] = queue.Queue()
|
||||||
self._reader: threading.Thread | None = None
|
self._reader: threading.Thread | None = None
|
||||||
|
self._logf = None
|
||||||
|
|
||||||
def alive(self) -> bool:
|
def alive(self) -> bool:
|
||||||
return self.proc is not None and self.proc.poll() is None
|
return self.proc is not None and self.proc.poll() is None
|
||||||
@@ -547,15 +615,18 @@ class _Worker:
|
|||||||
if self.alive():
|
if self.alive():
|
||||||
return
|
return
|
||||||
log.info("starte Bild-Worker: %s %s", IMAGE_PYTHON, IMAGE_WORKER)
|
log.info("starte Bild-Worker: %s %s", IMAGE_PYTHON, IMAGE_WORKER)
|
||||||
logf = open(IMAGE_WORKER_LOG, "ab")
|
self._logf = open(IMAGE_WORKER_LOG, "ab")
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
[IMAGE_PYTHON, IMAGE_WORKER],
|
[IMAGE_PYTHON, IMAGE_WORKER],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=logf,
|
stderr=self._logf,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
|
RUNTIME.save(worker="image", worker_pid=self.proc.pid,
|
||||||
|
phase="loading-image")
|
||||||
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
||||||
self._reader.start()
|
self._reader.start()
|
||||||
try:
|
try:
|
||||||
@@ -599,8 +670,19 @@ class _Worker:
|
|||||||
self.proc.wait(timeout=10)
|
self.proc.wait(timeout=10)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.proc.kill()
|
self.proc.kill()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
if self._logf is not None:
|
||||||
|
try:
|
||||||
|
self._logf.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._logf = None
|
||||||
self.proc = None
|
self.proc = None
|
||||||
self.model_loaded = False
|
self.model_loaded = False
|
||||||
|
RUNTIME.clear_worker("image")
|
||||||
|
|
||||||
|
|
||||||
def _worker() -> _Worker:
|
def _worker() -> _Worker:
|
||||||
@@ -670,13 +752,19 @@ def _restore_qwen(profile: str) -> None:
|
|||||||
"""Startet llama.cpp mit dem gemerkten Profil und wartet auf Readiness."""
|
"""Startet llama.cpp mit dem gemerkten Profil und wartet auf Readiness."""
|
||||||
log.info("stelle Qwen-Profil %s wieder her ...", profile)
|
log.info("stelle Qwen-Profil %s wieder her ...", profile)
|
||||||
try:
|
try:
|
||||||
subprocess.run([SYSTEMCTL_BIN, "start", LLAMA_SERVICE],
|
proc = subprocess.run([SYSTEMCTL_BIN, "start", LLAMA_SERVICE],
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
timeout=120)
|
timeout=120)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
out = proc.stdout.decode(errors="replace").strip()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"systemctl start {LLAMA_SERVICE} fehlgeschlagen "
|
||||||
|
f"(Exit {proc.returncode}): {out[-500:]}")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
log.error("systemctl start hat 120 s überschritten")
|
log.error("systemctl start hat 120 s überschritten")
|
||||||
_wait_ready(profile, time.monotonic() + SWITCH_TIMEOUT)
|
_wait_ready(profile, time.monotonic() + SWITCH_TIMEOUT)
|
||||||
|
RUNTIME.save(last_profile=profile, phase="idle")
|
||||||
|
|
||||||
|
|
||||||
def generate_image(prompt: str, width: int, height: int, steps: int,
|
def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||||
@@ -700,6 +788,7 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
|||||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||||
results: list[str] = []
|
results: list[str] = []
|
||||||
warning: str | None = None
|
warning: str | None = None
|
||||||
|
img.last_error = None
|
||||||
# Qwen wird gestoppt → für Chats nicht verfügbar (die warten).
|
# Qwen wird gestoppt → für Chats nicht verfügbar (die warten).
|
||||||
_set_qwen_unavailable(True)
|
_set_qwen_unavailable(True)
|
||||||
try:
|
try:
|
||||||
@@ -707,10 +796,16 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
|||||||
|
|
||||||
# 1) Qwen stoppen (VRAM freigeben).
|
# 1) Qwen stoppen (VRAM freigeben).
|
||||||
img.phase = "stopping-qwen"
|
img.phase = "stopping-qwen"
|
||||||
subprocess.run([SYSTEMCTL_BIN, "stop", LLAMA_SERVICE],
|
RUNTIME.save(last_profile=profile, phase=img.phase)
|
||||||
stdin=subprocess.DEVNULL,
|
proc = subprocess.run([SYSTEMCTL_BIN, "stop", LLAMA_SERVICE],
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
stdin=subprocess.DEVNULL,
|
||||||
timeout=120)
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT, timeout=120)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
out = proc.stdout.decode(errors="replace").strip()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"systemctl stop {LLAMA_SERVICE} fehlgeschlagen "
|
||||||
|
f"(Exit {proc.returncode}): {out[-500:]}")
|
||||||
_wait_upstream_down(time.monotonic() + 60)
|
_wait_upstream_down(time.monotonic() + 60)
|
||||||
|
|
||||||
# 2) Worker starten (Modell wird beim ersten generate geladen).
|
# 2) Worker starten (Modell wird beim ersten generate geladen).
|
||||||
@@ -763,6 +858,12 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
|||||||
log.info("Bild %d/%d: %s (%.1f s)", i + 1, n, filename,
|
log.info("Bild %d/%d: %s (%.1f s)", i + 1, n, filename,
|
||||||
resp.get("seconds", 0))
|
resp.get("seconds", 0))
|
||||||
|
|
||||||
|
removed = enforce_artifact_retention(
|
||||||
|
IMAGE_DIR, IMAGE_RETENTION_FILES, IMAGE_RETENTION_BYTES,
|
||||||
|
IMAGE_RETENTION_DAYS, protected=results)
|
||||||
|
if removed:
|
||||||
|
log.info("Bild-Retention: %d alte Bilder entfernt", len(removed))
|
||||||
|
|
||||||
# 4) Worker vollständig beenden (VRAM + CUDA-Kontext freigeben).
|
# 4) Worker vollständig beenden (VRAM + CUDA-Kontext freigeben).
|
||||||
img.phase = "unloading-image"
|
img.phase = "unloading-image"
|
||||||
worker.stop()
|
worker.stop()
|
||||||
@@ -804,8 +905,10 @@ def _image_filename_ok(name: str) -> bool:
|
|||||||
|
|
||||||
VISION_ANALYST_PROMPT = (
|
VISION_ANALYST_PROMPT = (
|
||||||
"Du bist ein reiner Bild- und Screenshot-Analyst. Du beantwortest die "
|
"Du bist ein reiner Bild- und Screenshot-Analyst. Du beantwortest die "
|
||||||
"Benutzerfrage NICHT selbst. Du extrahierst aus dem Bild alle "
|
"Benutzerfrage NICHT selbst. Erstelle eine umfassende, von der aktuellen "
|
||||||
"Informationen, die für die Beantwortung relevant sein könnten.\n\n"
|
"Frage unabhängige Bestandsaufnahme. Lasse keine sichtbaren Details aus, "
|
||||||
|
"damit auch spätere Folgefragen allein mit dieser Analyse beantwortet "
|
||||||
|
"werden können.\n\n"
|
||||||
"Antworte NUR mit einer strukturierten Analyse in dieser Form:\n"
|
"Antworte NUR mit einer strukturierten Analyse in dieser Form:\n"
|
||||||
"1. SIEHTBARER TEXT: alle Texte wörtlich und vollständig, mit Anordnung\n"
|
"1. SIEHTBARER TEXT: alle Texte wörtlich und vollständig, mit Anordnung\n"
|
||||||
"2. UI-ELEMENTE: Felder, Buttons, Menüs, Tabs, Dropdowns, Checkboxen – "
|
"2. UI-ELEMENTE: Felder, Buttons, Menüs, Tabs, Dropdowns, Checkboxen – "
|
||||||
@@ -815,8 +918,7 @@ VISION_ANALYST_PROMPT = (
|
|||||||
"links/rechts, Reihenfolge)\n"
|
"links/rechts, Reihenfolge)\n"
|
||||||
"5. ZUSTÄNDE: Statusanzeigen, Farben (rot/grün/gelb), Ladezustände\n"
|
"5. ZUSTÄNDE: Statusanzeigen, Farben (rot/grün/gelb), Ladezustände\n"
|
||||||
"6. OBJEKTE: relevante Objekte und Beziehungen zwischen Elementen\n"
|
"6. OBJEKTE: relevante Objekte und Beziehungen zwischen Elementen\n"
|
||||||
"7. WEITERES: alles Weitere, was für die Benutzerfrage relevant sein "
|
"7. WEITERES: alle übrigen sichtbaren Details\n\n"
|
||||||
"könnte\n\n"
|
|
||||||
"Regeln: Bei Screenshots hat Text- und UI-Genauigkeit Vorrang vor "
|
"Regeln: Bei Screenshots hat Text- und UI-Genauigkeit Vorrang vor "
|
||||||
"schöner Beschreibung. Keine Interpretation, keine Vermutungen – nur "
|
"schöner Beschreibung. Keine Interpretation, keine Vermutungen – nur "
|
||||||
"was sichtbar ist. Unleserliches als [unleserlich] markieren."
|
"was sichtbar ist. Unleserliches als [unleserlich] markieren."
|
||||||
@@ -869,7 +971,10 @@ class _VisionServer:
|
|||||||
self._logf = open(VISION_LOG, "ab")
|
self._logf = open(VISION_LOG, "ab")
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
cmd, stdin=subprocess.DEVNULL,
|
cmd, stdin=subprocess.DEVNULL,
|
||||||
stdout=self._logf, stderr=subprocess.STDOUT)
|
stdout=self._logf, stderr=subprocess.STDOUT,
|
||||||
|
start_new_session=True)
|
||||||
|
RUNTIME.save(worker="vision", worker_pid=self.proc.pid,
|
||||||
|
phase="loading-vision")
|
||||||
log.info("Vision-Server gestartet (PID %d, Port %d, ctx %d)",
|
log.info("Vision-Server gestartet (PID %d, Port %d, ctx %d)",
|
||||||
self.proc.pid, VISION_PORT, VISION_CTX)
|
self.proc.pid, VISION_PORT, VISION_CTX)
|
||||||
|
|
||||||
@@ -917,6 +1022,7 @@ class _VisionServer:
|
|||||||
pass
|
pass
|
||||||
self._logf = None
|
self._logf = None
|
||||||
self.proc = None
|
self.proc = None
|
||||||
|
RUNTIME.clear_worker("vision")
|
||||||
|
|
||||||
|
|
||||||
def _extract_last_user_image(data: dict) -> tuple[str | None, str]:
|
def _extract_last_user_image(data: dict) -> tuple[str | None, str]:
|
||||||
@@ -955,6 +1061,74 @@ def _extract_last_user_image(data: dict) -> tuple[str | None, str]:
|
|||||||
return None, ""
|
return None, ""
|
||||||
|
|
||||||
|
|
||||||
|
_VISION_DATA_TYPES = {
|
||||||
|
"image/jpeg", "image/png", "image/webp", "image/gif",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_vision_image(image_url: str) -> str:
|
||||||
|
"""Validate an image and return a bounded data URL for llama.cpp.
|
||||||
|
|
||||||
|
Remote fetches are off by default. If explicitly enabled, the router
|
||||||
|
downloads the file itself after rejecting local/private destinations and
|
||||||
|
hands llama.cpp a data URL. The model server therefore never receives an
|
||||||
|
arbitrary user-controlled URL (SSRF protection).
|
||||||
|
"""
|
||||||
|
if image_url.startswith("data:"):
|
||||||
|
header, separator, payload = image_url.partition(",")
|
||||||
|
match = re.fullmatch(
|
||||||
|
r"data:([a-zA-Z0-9.+-]+/[a-zA-Z0-9.+-]+);base64", header)
|
||||||
|
if not separator or not match or match.group(1).lower() not in _VISION_DATA_TYPES:
|
||||||
|
raise ValueError("ungültige oder nicht unterstützte Bild-data-URL")
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(payload, validate=True)
|
||||||
|
except (ValueError, binascii.Error):
|
||||||
|
raise ValueError("ungültige Base64-Bilddaten") from None
|
||||||
|
if not raw or len(raw) > VISION_MAX_IMAGE_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Bildgröße außerhalb des Limits (max {VISION_MAX_IMAGE_BYTES} Bytes)")
|
||||||
|
return image_url
|
||||||
|
|
||||||
|
parsed = urllib.parse.urlsplit(image_url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
raise ValueError("Bild muss eine data-URL oder eine gültige HTTP(S)-URL sein")
|
||||||
|
if not VISION_ALLOW_REMOTE_URLS:
|
||||||
|
raise ValueError(
|
||||||
|
"Remote-Bild-URLs sind deaktiviert; Bild bitte als data-URL hochladen")
|
||||||
|
try:
|
||||||
|
addresses = socket.getaddrinfo(
|
||||||
|
parsed.hostname,
|
||||||
|
parsed.port or (443 if parsed.scheme == "https" else 80))
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise ValueError(f"Bild-Host kann nicht aufgelöst werden: {exc}") from None
|
||||||
|
for address in addresses:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(address[4][0])
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("Bild-Host liefert eine ungültige Adresse") from None
|
||||||
|
if not ip.is_global:
|
||||||
|
raise ValueError("private/lokale Bild-URLs sind nicht erlaubt")
|
||||||
|
|
||||||
|
request = urllib.request.Request(
|
||||||
|
image_url, headers={"User-Agent": "AI-Profile-Router/2.0"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
final_url = urllib.parse.urlsplit(response.geturl())
|
||||||
|
if final_url.hostname != parsed.hostname:
|
||||||
|
raise ValueError("Weiterleitungen zu einem anderen Bild-Host sind nicht erlaubt")
|
||||||
|
content_type = response.headers.get_content_type().lower()
|
||||||
|
if content_type not in _VISION_DATA_TYPES:
|
||||||
|
raise ValueError(f"Remote-Inhalt ist kein unterstütztes Bild ({content_type})")
|
||||||
|
raw = response.read(VISION_MAX_IMAGE_BYTES + 1)
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise ValueError(f"Remote-Bild kann nicht geladen werden: {exc}") from None
|
||||||
|
if not raw or len(raw) > VISION_MAX_IMAGE_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Bildgröße außerhalb des Limits (max {VISION_MAX_IMAGE_BYTES} Bytes)")
|
||||||
|
return (f"data:{content_type};base64,"
|
||||||
|
+ base64.b64encode(raw).decode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
def _image_hash(image_url: str) -> str:
|
def _image_hash(image_url: str) -> str:
|
||||||
"""Stabiler Hash für ein Bild (Base64-Payload oder URL).
|
"""Stabiler Hash für ein Bild (Base64-Payload oder URL).
|
||||||
|
|
||||||
@@ -1064,9 +1238,11 @@ def _vision_analyze(image_url: str, question: str) -> str:
|
|||||||
{"role": "user", "content": [
|
{"role": "user", "content": [
|
||||||
{"type": "image_url", "image_url": {"url": image_url}},
|
{"type": "image_url", "image_url": {"url": image_url}},
|
||||||
{"type": "text",
|
{"type": "text",
|
||||||
"text": ("Benutzerfrage (nur zur Orientierung, NICHT "
|
"text": ("Aktuelle Benutzerfrage nur als zusätzlicher "
|
||||||
"beantworten: " + (question or "(keine Frage)")
|
"Kontext, ohne die Analyse darauf zu beschränken: "
|
||||||
+ "\n\nErstelle jetzt die strukturierte Vision-Analyse.")},
|
+ (question or "(keine Frage)")
|
||||||
|
+ "\n\nErstelle jetzt eine vollständige, "
|
||||||
|
"fragenunabhängige Vision-Analyse.")},
|
||||||
]},
|
]},
|
||||||
],
|
],
|
||||||
"max_tokens": VISION_MAX_TOKENS,
|
"max_tokens": VISION_MAX_TOKENS,
|
||||||
@@ -1131,6 +1307,7 @@ def _vision_swap(data: dict) -> str:
|
|||||||
if not image_url:
|
if not image_url:
|
||||||
raise RuntimeError("kein Bild im Request")
|
raise RuntimeError("kein Bild im Request")
|
||||||
img_hash = _image_hash(image_url)
|
img_hash = _image_hash(image_url)
|
||||||
|
safe_image_url = _normalize_vision_image(image_url)
|
||||||
vis.last_profile = profile
|
vis.last_profile = profile
|
||||||
vis.last_error = None
|
vis.last_error = None
|
||||||
t_total = time.monotonic()
|
t_total = time.monotonic()
|
||||||
@@ -1148,11 +1325,17 @@ def _vision_swap(data: dict) -> str:
|
|||||||
|
|
||||||
# 1) Hauptprofil entladen (VRAM freigeben).
|
# 1) Hauptprofil entladen (VRAM freigeben).
|
||||||
vis.phase = "stopping-main"
|
vis.phase = "stopping-main"
|
||||||
|
RUNTIME.save(last_profile=profile, phase=vis.phase)
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
subprocess.run([SYSTEMCTL_BIN, "stop", LLAMA_SERVICE],
|
proc = subprocess.run([SYSTEMCTL_BIN, "stop", LLAMA_SERVICE],
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
stdout=subprocess.PIPE,
|
||||||
timeout=120)
|
stderr=subprocess.STDOUT, timeout=120)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
out = proc.stdout.decode(errors="replace").strip()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"systemctl stop {LLAMA_SERVICE} fehlgeschlagen "
|
||||||
|
f"(Exit {proc.returncode}): {out[-500:]}")
|
||||||
_wait_upstream_down(time.monotonic() + 60)
|
_wait_upstream_down(time.monotonic() + 60)
|
||||||
timings["main_unload"] = time.monotonic() - t0
|
timings["main_unload"] = time.monotonic() - t0
|
||||||
log.info("Vision: Hauptprofil %s entladen (%.1f s)",
|
log.info("Vision: Hauptprofil %s entladen (%.1f s)",
|
||||||
@@ -1171,7 +1354,7 @@ def _vision_swap(data: dict) -> str:
|
|||||||
vis.phase = "analyzing"
|
vis.phase = "analyzing"
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
log.info("Vision: Inferenz gestartet (127.0.0.1:%d)", VISION_PORT)
|
log.info("Vision: Inferenz gestartet (127.0.0.1:%d)", VISION_PORT)
|
||||||
analysis = _vision_analyze(image_url, question)
|
analysis = _vision_analyze(safe_image_url, question)
|
||||||
timings["vision_infer"] = time.monotonic() - t0
|
timings["vision_infer"] = time.monotonic() - t0
|
||||||
log.info("Vision: Inferenz abgeschlossen (%.1f s, %d Zeichen)",
|
log.info("Vision: Inferenz abgeschlossen (%.1f s, %d Zeichen)",
|
||||||
timings["vision_infer"], len(analysis))
|
timings["vision_infer"], len(analysis))
|
||||||
@@ -1243,7 +1426,8 @@ def _vision_swap(data: dict) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "AIProfileRouter/1.0"
|
server_version = "AIProfileRouter/2.0"
|
||||||
|
sys_version = ""
|
||||||
timeout = 60 # Socket-Timeout für Client-Requests (s)
|
timeout = 60 # Socket-Timeout für Client-Requests (s)
|
||||||
|
|
||||||
# ---------- Routing ----------
|
# ---------- Routing ----------
|
||||||
@@ -1254,13 +1438,51 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
self._route()
|
self._route()
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
self._route()
|
||||||
|
|
||||||
|
def do_PATCH(self):
|
||||||
|
self._route()
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
self._route()
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self._route()
|
||||||
|
|
||||||
def _route(self):
|
def _route(self):
|
||||||
path = self.path.split("?", 1)[0]
|
path = self.path.split("?", 1)[0]
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
slot_acquired = False
|
||||||
try:
|
try:
|
||||||
if path == "/v1/models" and self.command == "GET":
|
if path == "/health" and self.command == "GET":
|
||||||
|
# Liveness: der Routerprozess lebt. Ein absichtlich entladenes
|
||||||
|
# Qwen (Vision/Bild) darf keinen Restart-Loop auslösen.
|
||||||
|
self._send_json(200, {"status": "ok", "router": "alive"})
|
||||||
|
return
|
||||||
|
if path == "/ready" and self.command == "GET":
|
||||||
|
up = upstream_status()
|
||||||
|
active = current_profile()
|
||||||
|
with STATE.avail_lock:
|
||||||
|
unavailable = STATE.qwen_unavailable
|
||||||
|
ready = bool(active in PROFILES and not unavailable
|
||||||
|
and _profile_is_ready(active, up))
|
||||||
|
self._send_json(200 if ready else 503, {
|
||||||
|
"status": "ok" if ready else "degraded",
|
||||||
|
"router": "alive",
|
||||||
|
"upstream": "ready" if ready else "unavailable",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
slot_acquired = REQUEST_SLOTS.acquire(blocking=False)
|
||||||
|
if not slot_acquired:
|
||||||
|
self._send_error(429, "Router ist ausgelastet; bitte erneut versuchen",
|
||||||
|
"server_error", "too_many_requests")
|
||||||
|
return
|
||||||
|
if not self._authorized():
|
||||||
|
self._send_auth_required()
|
||||||
|
elif path == "/v1/models" and self.command == "GET":
|
||||||
self._send_json(200, self._models_payload())
|
self._send_json(200, self._models_payload())
|
||||||
elif path == "/status":
|
elif path == "/status" and self.command == "GET":
|
||||||
self._send_json(200, self._status_payload())
|
self._send_json(200, self._status_payload())
|
||||||
elif path == "/v1/audio/models" and self.command == "GET":
|
elif path == "/v1/audio/models" and self.command == "GET":
|
||||||
self._send_json(200, self._audio_models_payload())
|
self._send_json(200, self._audio_models_payload())
|
||||||
@@ -1278,13 +1500,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._images_list()
|
self._images_list()
|
||||||
elif path.startswith("/images/") and self.command == "GET":
|
elif path.startswith("/images/") and self.command == "GET":
|
||||||
self._image_serve(path[len("/images/"):])
|
self._image_serve(path[len("/images/"):])
|
||||||
elif path in ("/fast", "/medium", "/long"):
|
elif (path in ("/fast", "/medium", "/long")
|
||||||
|
and (self.command == "POST"
|
||||||
|
or (self.command == "GET" and ALLOW_LEGACY_GET_SWITCH))):
|
||||||
self._switch(path[1:])
|
self._switch(path[1:])
|
||||||
elif (self.command == "POST" and path.startswith("/")
|
elif (path in ("/fast", "/medium", "/long")
|
||||||
and path.count("/") == 1):
|
and self.command == "GET"):
|
||||||
# Kommandonamensraum: unbekanntes Profil
|
self._send_error(405, "Profilwechsel erfordert POST",
|
||||||
self._send_error(400, f"unbekanntes Profil: {path[1:]}",
|
"invalid_request_error", "method_not_allowed")
|
||||||
"invalid_request_error", "invalid_profile")
|
|
||||||
else:
|
else:
|
||||||
self._forward()
|
self._forward()
|
||||||
except BrokenPipeError:
|
except BrokenPipeError:
|
||||||
@@ -1293,9 +1516,31 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
log.exception("Fehler bei %s %s", self.command, path)
|
log.exception("Fehler bei %s %s", self.command, path)
|
||||||
self._safe_error(500, "interner Router-Fehler")
|
self._safe_error(500, "interner Router-Fehler")
|
||||||
finally:
|
finally:
|
||||||
|
if slot_acquired:
|
||||||
|
REQUEST_SLOTS.release()
|
||||||
log.info("%s %s -> %s in %.3f s", self.command, path,
|
log.info("%s %s -> %s in %.3f s", self.command, path,
|
||||||
getattr(self, "_last_code", "-"), time.monotonic() - started)
|
getattr(self, "_last_code", "-"), time.monotonic() - started)
|
||||||
|
|
||||||
|
def _authorized(self) -> bool:
|
||||||
|
assert AUTH is not None
|
||||||
|
return AUTH.accepts(self.headers.get("Authorization"),
|
||||||
|
self.headers.get("X-API-Key"))
|
||||||
|
|
||||||
|
def _send_auth_required(self) -> None:
|
||||||
|
body = json.dumps({"error": {
|
||||||
|
"message": "gültiger Router-API-Key erforderlich",
|
||||||
|
"type": "authentication_error",
|
||||||
|
"code": "invalid_api_key",
|
||||||
|
}}).encode()
|
||||||
|
self._last_code = 401
|
||||||
|
self.send_response(401)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("WWW-Authenticate", "Bearer")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
# ---------- Request-Body-Lesen (Content-Length + chunked) ----------
|
# ---------- Request-Body-Lesen (Content-Length + chunked) ----------
|
||||||
|
|
||||||
def _read_body(self) -> bytes:
|
def _read_body(self) -> bytes:
|
||||||
@@ -1436,7 +1681,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"ctx": up.get("ctx"),
|
"ctx": up.get("ctx"),
|
||||||
},
|
},
|
||||||
"qwen": {
|
"qwen": {
|
||||||
"available": not qwen_unavailable,
|
"available": (not qwen_unavailable and up["reachable"]
|
||||||
|
and bool(up.get("model"))),
|
||||||
"active_chats": active_chats,
|
"active_chats": active_chats,
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
@@ -1965,7 +2211,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
data = None
|
data = None
|
||||||
# Virtuelles Modell? -> Profil sicherstellen, dann Modell ersetzen.
|
requested_profile: str | None = None
|
||||||
|
# Virtuelles Modell erkennen. Umschalten und Chat-Lease werden weiter
|
||||||
|
# unten atomar unter dem zentralen Orchestrierungs-Lock ausgeführt.
|
||||||
if body is not None and self.path.startswith("/v1/"):
|
if body is not None and self.path.startswith("/v1/"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(body)
|
data = json.loads(body)
|
||||||
@@ -1973,57 +2221,98 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
data = None
|
data = None
|
||||||
model = data.get("model") if isinstance(data, dict) else None
|
model = data.get("model") if isinstance(data, dict) else None
|
||||||
if isinstance(model, str) and model in VIRTUAL_MODELS:
|
if isinstance(model, str) and model in VIRTUAL_MODELS:
|
||||||
profile = VIRTUAL_MODELS[model]
|
requested_profile = VIRTUAL_MODELS[model]
|
||||||
try:
|
|
||||||
switch_profile(profile, implicit=True)
|
|
||||||
except (ValueError, RuntimeError) as e:
|
|
||||||
self._send_error(502, str(e), "server_error",
|
|
||||||
"upstream_unavailable")
|
|
||||||
return
|
|
||||||
up = upstream_status()
|
|
||||||
if not up["reachable"] or not up.get("model"):
|
|
||||||
self._send_error(502, "llama.cpp nicht erreichbar",
|
|
||||||
"server_error", "upstream_unavailable")
|
|
||||||
return
|
|
||||||
data["model"] = up["model"]
|
|
||||||
body = json.dumps(data).encode()
|
|
||||||
elif isinstance(model, str) and model.startswith("qwen-"):
|
elif isinstance(model, str) and model.startswith("qwen-"):
|
||||||
# qwen-* ist der Namensraum des Routers
|
# qwen-* ist der Namensraum des Routers
|
||||||
self._send_error(400, f"unbekanntes virtuelles Modell: {model}",
|
self._send_error(400, f"unbekanntes virtuelles Modell: {model}",
|
||||||
"invalid_request_error", "unknown_model")
|
"invalid_request_error", "unknown_model")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Vision: Bilder im Request → Q3-Vision-Analyse (nur für NEUE,
|
# Alle modellbezogenen Requests erhalten eine atomare Lease. Damit
|
||||||
# noch nicht analysierte Bilder), danach erzeugt das
|
# kann kein zweiter Client zwischen Profilwahl und Upstream-Request das
|
||||||
# (wiederhergestellte) Hauptmodell die Endantwort. Die gesamte
|
# Modell austauschen. Vision-Vorbereitung gehört zur selben Transaktion.
|
||||||
# History wird sanisiert: alle Bild-Parts werden durch ihre
|
|
||||||
# (gecachten) Vision-Analysen ersetzt, damit das Nicht-Vision-
|
|
||||||
# Hauptmodell (Fast/Medium/Long) keine Bilddaten bekommt. Das ist
|
|
||||||
# wichtig, weil Open WebUI bei Folgefragen den ursprünglichen
|
|
||||||
# multimodalen Verlauf erneut mitsendet.
|
|
||||||
if isinstance(data, dict) and path == "/v1/chat/completions":
|
if isinstance(data, dict) and path == "/v1/chat/completions":
|
||||||
image_url, _ = _extract_last_user_image(data)
|
self._chat_proxy(body, data, requested_profile)
|
||||||
if image_url:
|
return
|
||||||
if _vision_cached_analysis(_image_hash(image_url)) is None:
|
if requested_profile is not None:
|
||||||
# Neues Bild → Vision-Hotswap (Q3 analysiert + cacht).
|
self._profiled_proxy(body, data, requested_profile)
|
||||||
self.timeout = None # Vision-Swap kann Minuten dauern
|
return
|
||||||
try:
|
|
||||||
_vision_swap(data)
|
|
||||||
except (ValueError, RuntimeError) as e:
|
|
||||||
self._send_error(502, str(e), "server_error",
|
|
||||||
"vision_failed")
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
log.info("Vision: Bild bereits analysiert "
|
|
||||||
"(Cache-Treffer) – kein Hotswap")
|
|
||||||
if _request_has_image(data):
|
|
||||||
data = _sanitize_for_main_model(data)
|
|
||||||
body = json.dumps(data).encode()
|
|
||||||
log.info("Vision: finale Hauptmodell-Inferenz gestartet")
|
|
||||||
|
|
||||||
# An llama.cpp weiterleiten (mit Chat-Waiting, Streaming bleibt erhalten).
|
# An llama.cpp weiterleiten (mit Chat-Waiting, Streaming bleibt erhalten).
|
||||||
self._proxy_with_wait(body)
|
self._proxy_with_wait(body)
|
||||||
|
|
||||||
|
def _acquire_model_lease(self, profile: str | None = None) -> dict:
|
||||||
|
"""Atomar Profil sicherstellen und einen aktiven Request registrieren."""
|
||||||
|
with STATE.lock:
|
||||||
|
if profile is not None:
|
||||||
|
switch_profile(profile, implicit=True)
|
||||||
|
up = upstream_status()
|
||||||
|
if not up["reachable"] or not up.get("model"):
|
||||||
|
raise RuntimeError("llama.cpp nicht erreichbar")
|
||||||
|
with STATE.avail_lock:
|
||||||
|
if STATE.qwen_unavailable:
|
||||||
|
raise RuntimeError("Qwen wird gerade neu geladen")
|
||||||
|
STATE.active_chats += 1
|
||||||
|
return up
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _release_model_lease() -> None:
|
||||||
|
with STATE.avail_lock:
|
||||||
|
STATE.active_chats = max(0, STATE.active_chats - 1)
|
||||||
|
|
||||||
|
def _profiled_proxy(self, body: bytes | None, data: dict,
|
||||||
|
profile: str) -> None:
|
||||||
|
try:
|
||||||
|
up = self._acquire_model_lease(profile)
|
||||||
|
except (ValueError, RuntimeError) as e:
|
||||||
|
self._send_error(502, str(e), "server_error",
|
||||||
|
"upstream_unavailable")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data["model"] = up["model"]
|
||||||
|
self._proxy(json.dumps(data).encode())
|
||||||
|
finally:
|
||||||
|
self._release_model_lease()
|
||||||
|
|
||||||
|
def _chat_proxy(self, body: bytes | None, data: dict,
|
||||||
|
profile: str | None) -> None:
|
||||||
|
"""Vision-Vorbereitung, Profilwahl und Chat-Lease als eine Transaktion."""
|
||||||
|
lease_acquired = False
|
||||||
|
try:
|
||||||
|
with STATE.lock:
|
||||||
|
if profile is not None:
|
||||||
|
switch_profile(profile, implicit=True)
|
||||||
|
|
||||||
|
image_url, _ = _extract_last_user_image(data)
|
||||||
|
if image_url:
|
||||||
|
if _vision_cached_analysis(_image_hash(image_url)) is None:
|
||||||
|
self.timeout = None
|
||||||
|
_vision_swap(data)
|
||||||
|
else:
|
||||||
|
log.info("Vision: Bild bereits analysiert "
|
||||||
|
"(Cache-Treffer) – kein Hotswap")
|
||||||
|
if _request_has_image(data):
|
||||||
|
data = _sanitize_for_main_model(data)
|
||||||
|
log.info("Vision: finale Hauptmodell-Inferenz gestartet")
|
||||||
|
|
||||||
|
up = upstream_status()
|
||||||
|
if not up["reachable"] or not up.get("model"):
|
||||||
|
raise RuntimeError("llama.cpp nicht erreichbar")
|
||||||
|
if profile is not None:
|
||||||
|
data["model"] = up["model"]
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
with STATE.avail_lock:
|
||||||
|
if STATE.qwen_unavailable:
|
||||||
|
raise RuntimeError("Qwen wird gerade neu geladen")
|
||||||
|
STATE.active_chats += 1
|
||||||
|
lease_acquired = True
|
||||||
|
self._proxy(body)
|
||||||
|
except (ValueError, RuntimeError) as e:
|
||||||
|
self._send_error(502, str(e), "server_error", "upstream_unavailable")
|
||||||
|
finally:
|
||||||
|
if lease_acquired:
|
||||||
|
self._release_model_lease()
|
||||||
|
|
||||||
def _proxy_with_wait(self, body: bytes | None) -> None:
|
def _proxy_with_wait(self, body: bytes | None) -> None:
|
||||||
"""Leitet an llama.cpp weiter, wartet aber erst, bis Qwen verfügbar ist.
|
"""Leitet an llama.cpp weiter, wartet aber erst, bis Qwen verfügbar ist.
|
||||||
|
|
||||||
@@ -2128,15 +2417,86 @@ class _FlushHandler(logging.StreamHandler):
|
|||||||
self.flush()
|
self.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class RouterHTTPServer(ThreadingHTTPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
def _startup_reconcile() -> None:
|
||||||
|
"""Reconcile persisted worker/model state before accepting requests."""
|
||||||
|
previous = RUNTIME.load()
|
||||||
|
worker = previous.get("worker")
|
||||||
|
if worker in {"image", "vision"}:
|
||||||
|
markers = ([IMAGE_WORKER] if worker == "image" else
|
||||||
|
[VISION_ALIAS, VISION_MODEL, str(VISION_PORT)])
|
||||||
|
terminated = terminate_recorded_worker(
|
||||||
|
previous, markers, lambda msg: log.warning("Recovery: %s", msg))
|
||||||
|
if terminated:
|
||||||
|
time.sleep(1)
|
||||||
|
RUNTIME.clear_worker(worker)
|
||||||
|
|
||||||
|
removed = enforce_artifact_retention(
|
||||||
|
IMAGE_DIR, IMAGE_RETENTION_FILES, IMAGE_RETENTION_BYTES,
|
||||||
|
IMAGE_RETENTION_DAYS)
|
||||||
|
if removed:
|
||||||
|
log.info("Startup-Retention: %d alte Bilder entfernt", len(removed))
|
||||||
|
|
||||||
|
profile = current_profile()
|
||||||
|
if profile is None:
|
||||||
|
saved = previous.get("last_profile")
|
||||||
|
if saved in PROFILES:
|
||||||
|
try:
|
||||||
|
switch_profile(saved)
|
||||||
|
log.info("Recovery: gespeichertes Profil %s neu angewendet", saved)
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Recovery: gespeichertes Profil %s konnte nicht "
|
||||||
|
"angewendet werden: %s", saved, exc)
|
||||||
|
profile = None
|
||||||
|
if profile is None:
|
||||||
|
log.error("Recovery: kein gültiges Profil gefunden; Router startet degraded")
|
||||||
|
_set_qwen_unavailable(True)
|
||||||
|
return
|
||||||
|
|
||||||
|
up = upstream_status()
|
||||||
|
if (up["reachable"] and up.get("model")
|
||||||
|
and up.get("ctx") == PROFILES[profile]
|
||||||
|
and (not EXPECTED_MODELS.get(profile)
|
||||||
|
or up.get("model") == EXPECTED_MODELS[profile])):
|
||||||
|
_set_qwen_unavailable(False)
|
||||||
|
RUNTIME.save(last_profile=profile, phase="idle")
|
||||||
|
log.info("Recovery: Profil %s ist bereits bereit", profile)
|
||||||
|
return
|
||||||
|
_set_qwen_unavailable(True)
|
||||||
|
try:
|
||||||
|
_restore_qwen(profile)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Recovery: Profil %s konnte nicht gestartet werden: %s",
|
||||||
|
profile, exc)
|
||||||
|
return
|
||||||
|
_set_qwen_unavailable(False)
|
||||||
|
log.info("Recovery: Profil %s wurde wiederhergestellt", profile)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
global AUTH
|
||||||
handler = _FlushHandler(sys.stdout)
|
handler = _FlushHandler(sys.stdout)
|
||||||
handler.setFormatter(logging.Formatter(
|
handler.setFormatter(logging.Formatter(
|
||||||
"%(asctime)s %(levelname)s %(message)s"))
|
"%(asctime)s %(levelname)s %(message)s"))
|
||||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"),
|
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||||
handlers=[handler])
|
handlers=[handler])
|
||||||
|
try:
|
||||||
|
AUTH = AuthPolicy.from_environment()
|
||||||
|
if not AUTH.enabled and HOST not in {"127.0.0.1", "::1", "localhost"}:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"ROUTER_AUTH_MODE=off ist nur an einer Loopback-Adresse erlaubt")
|
||||||
|
except ConfigurationError as exc:
|
||||||
|
log.critical("Unsichere Router-Konfiguration: %s", exc)
|
||||||
|
raise SystemExit(2)
|
||||||
log.info("AI Profile Router startet: %s:%s -> %s (Profile: %s)",
|
log.info("AI Profile Router startet: %s:%s -> %s (Profile: %s)",
|
||||||
HOST, PORT, UPSTREAM_URL, ", ".join(PROFILES))
|
HOST, PORT, UPSTREAM_URL, ", ".join(PROFILES))
|
||||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
log.info("Authentifizierung: %s", "aktiv" if AUTH.enabled else "deaktiviert")
|
||||||
|
_startup_reconcile()
|
||||||
|
server = RouterHTTPServer((HOST, PORT), Handler)
|
||||||
server.daemon_threads = True
|
server.daemon_threads = True
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"fast": {
|
||||||
|
"context": 73728,
|
||||||
|
"model_alias": "qwen38-27b-iq4mix-72k-mtp2"
|
||||||
|
},
|
||||||
|
"medium": {
|
||||||
|
"context": 94208,
|
||||||
|
"model_alias": "qwen38-27b-iq4xs-pure-92k"
|
||||||
|
},
|
||||||
|
"long": {
|
||||||
|
"context": 131072,
|
||||||
|
"model_alias": "qwen38-27b-iq4mix-128k-mtp2-ffn12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Security and runtime helpers for the AI Profile Router.
|
||||||
|
|
||||||
|
This module deliberately contains no model-specific logic. It provides the
|
||||||
|
small, testable building blocks that the HTTP gateway and the GPU orchestrator
|
||||||
|
share: API-key authentication, crash-state persistence, Linux child-process
|
||||||
|
cleanup and bounded artifact retention.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(RuntimeError):
|
||||||
|
"""Raised when the router would otherwise start in an unsafe state."""
|
||||||
|
|
||||||
|
|
||||||
|
def load_profile_registry(path: str | None) -> dict[str, dict]:
|
||||||
|
"""Load and validate the optional profile registry.
|
||||||
|
|
||||||
|
Passing no path keeps source-tree compatibility. An explicitly configured
|
||||||
|
but missing registry is a fatal configuration error: production must never
|
||||||
|
silently lose alias validation because a deployment forgot one file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fallback = {
|
||||||
|
"fast": {"context": 73728, "model_alias": None},
|
||||||
|
"medium": {"context": 94208, "model_alias": None},
|
||||||
|
"long": {"context": 131072, "model_alias": None},
|
||||||
|
}
|
||||||
|
if not path:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise ConfigurationError(f"Profilregister kann nicht gelesen werden: {exc}")
|
||||||
|
profiles = raw.get("profiles") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(profiles, dict) or not profiles:
|
||||||
|
raise ConfigurationError("Profilregister enthält keine 'profiles'")
|
||||||
|
validated: dict[str, dict] = {}
|
||||||
|
for name, definition in profiles.items():
|
||||||
|
if not isinstance(name, str) or not re_full_profile_name(name):
|
||||||
|
raise ConfigurationError(f"ungültiger Profilname: {name!r}")
|
||||||
|
if not isinstance(definition, dict):
|
||||||
|
raise ConfigurationError(f"Profil {name!r} ist kein Objekt")
|
||||||
|
context = definition.get("context")
|
||||||
|
alias = definition.get("model_alias")
|
||||||
|
if not isinstance(context, int) or context < 1024:
|
||||||
|
raise ConfigurationError(f"ungültiger Kontext für Profil {name!r}")
|
||||||
|
if alias is not None and (not isinstance(alias, str) or not alias.strip()):
|
||||||
|
raise ConfigurationError(f"ungültiger Modellalias für Profil {name!r}")
|
||||||
|
validated[name] = {"context": context,
|
||||||
|
"model_alias": alias.strip() if alias else None}
|
||||||
|
return validated
|
||||||
|
|
||||||
|
|
||||||
|
def re_full_profile_name(value: str) -> bool:
|
||||||
|
return bool(value) and all(ch.isalnum() or ch in "-_" for ch in value)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthPolicy:
|
||||||
|
"""Bearer/X-API-Key authentication policy.
|
||||||
|
|
||||||
|
``mode`` is either ``required`` or ``off``. ``off`` is intended only for
|
||||||
|
loopback development tests. Production startup fails closed when the key
|
||||||
|
is missing or too short.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mode: str
|
||||||
|
api_key: str | None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environment(cls) -> "AuthPolicy":
|
||||||
|
mode = os.environ.get("ROUTER_AUTH_MODE", "required").strip().lower()
|
||||||
|
if mode not in {"required", "off"}:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"ROUTER_AUTH_MODE muss 'required' oder 'off' sein")
|
||||||
|
if mode == "off":
|
||||||
|
return cls(mode=mode, api_key=None)
|
||||||
|
|
||||||
|
key = os.environ.get("ROUTER_API_KEY", "").strip()
|
||||||
|
key_file = os.environ.get(
|
||||||
|
"ROUTER_API_KEY_FILE", "/etc/mike-ai/router-api-key")
|
||||||
|
if not key and key_file:
|
||||||
|
try:
|
||||||
|
key = Path(key_file).read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if len(key) < 32:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"Router-Authentifizierung ist aktiv, aber kein API-Key mit "
|
||||||
|
"mindestens 32 Zeichen vorhanden")
|
||||||
|
return cls(mode=mode, api_key=key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self.mode == "required"
|
||||||
|
|
||||||
|
def accepts(self, authorization: str | None,
|
||||||
|
x_api_key: str | None) -> bool:
|
||||||
|
if not self.enabled:
|
||||||
|
return True
|
||||||
|
candidate = (x_api_key or "").strip()
|
||||||
|
auth = (authorization or "").strip()
|
||||||
|
if not candidate and auth.lower().startswith("bearer "):
|
||||||
|
candidate = auth[7:].strip()
|
||||||
|
return bool(candidate and self.api_key
|
||||||
|
and hmac.compare_digest(candidate, self.api_key))
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeStore:
|
||||||
|
"""Tiny atomic JSON store used for crash reconciliation."""
|
||||||
|
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def load(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
def save(self, **updates) -> None:
|
||||||
|
with self._lock:
|
||||||
|
state = self.load()
|
||||||
|
for key, value in updates.items():
|
||||||
|
if value is None:
|
||||||
|
state.pop(key, None)
|
||||||
|
else:
|
||||||
|
state[key] = value
|
||||||
|
state["updated_at"] = int(time.time())
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp = self.path.with_name(
|
||||||
|
f".{self.path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
||||||
|
try:
|
||||||
|
temp.write_text(
|
||||||
|
json.dumps(state, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
os.chmod(temp, 0o600)
|
||||||
|
os.replace(temp, self.path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
temp.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clear_worker(self, worker: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
state = self.load()
|
||||||
|
if state.get("worker") == worker:
|
||||||
|
self.save(worker=None, worker_pid=None)
|
||||||
|
|
||||||
|
|
||||||
|
def terminate_recorded_worker(state: dict, allowed_markers: Iterable[str],
|
||||||
|
log: Callable[[str], None]) -> bool:
|
||||||
|
"""Terminate a previously recorded worker, but only after cmdline checks."""
|
||||||
|
|
||||||
|
pid = state.get("worker_pid")
|
||||||
|
if not isinstance(pid, int) or pid <= 1:
|
||||||
|
return False
|
||||||
|
cmdline_path = Path(f"/proc/{pid}/cmdline")
|
||||||
|
try:
|
||||||
|
cmdline = cmdline_path.read_bytes().replace(b"\0", b" ").decode(
|
||||||
|
errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if not any(marker in cmdline for marker in allowed_markers):
|
||||||
|
log(f"Recorded PID {pid} nicht beendet: Prozessprüfung fehlgeschlagen")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
log(f"Verwaister Router-Worker PID {pid} wurde beendet")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_artifact_retention(directory: str, max_files: int, max_bytes: int,
|
||||||
|
max_age_days: int,
|
||||||
|
protected: Iterable[str] = ()) -> list[str]:
|
||||||
|
"""Delete oldest PNG + sidecar pairs until all retention limits hold."""
|
||||||
|
|
||||||
|
root = Path(directory)
|
||||||
|
if not root.is_dir():
|
||||||
|
return []
|
||||||
|
protected_set = set(protected)
|
||||||
|
now = time.time()
|
||||||
|
cutoff = now - max_age_days * 86400 if max_age_days > 0 else None
|
||||||
|
items: list[tuple[float, int, Path]] = []
|
||||||
|
for path in root.glob("*.png"):
|
||||||
|
if path.name in protected_set:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
sidecar = path.with_suffix(".json")
|
||||||
|
size = stat.st_size
|
||||||
|
try:
|
||||||
|
size += sidecar.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
items.append((stat.st_mtime, size, path))
|
||||||
|
|
||||||
|
items.sort(key=lambda item: item[0])
|
||||||
|
total = sum(item[1] for item in items)
|
||||||
|
removed: list[str] = []
|
||||||
|
while items:
|
||||||
|
mtime, size, path = items[0]
|
||||||
|
too_old = cutoff is not None and mtime < cutoff
|
||||||
|
too_many = max_files > 0 and len(items) > max_files
|
||||||
|
too_large = max_bytes > 0 and total > max_bytes
|
||||||
|
if not (too_old or too_many or too_large):
|
||||||
|
break
|
||||||
|
items.pop(0)
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
removed.append(path.name)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
path.with_suffix(".json").unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
total -= size
|
||||||
|
return removed
|
||||||
Reference in New Issue
Block a user