Replace vision hotswap with native multimodal profiles

This commit is contained in:
Mikei386
2026-08-20 14:24:56 +02:00
parent 8a45a0d851
commit cb07779f5a
19 changed files with 169 additions and 719 deletions
+18 -51
View File
@@ -11,10 +11,10 @@ Clients (Open WebUI, Hermes, Apps)
| |
v v
AI Profile Router :8081 AI Profile Router :8081
|-- qwen-fast (72K, maximale Geschwindigkeit) |-- qwen-fast (76.8K, maximale Geschwindigkeit)
|-- qwen-medium (92K, reines IQ4_XS) |-- qwen-medium (92K, reines IQ4_XS)
|-- qwen-long (128K, CPU-Offload) |-- qwen-long (128K, CPU-Offload)
|-- Vision-Hotswap |-- integrierte Qwen-Vision (kein Hotswap)
|-- lokale Bildgenerierung |-- lokale Bildgenerierung
|-- Whisper STT |-- Whisper STT
`-- XTTS TTS `-- XTTS TTS
@@ -61,8 +61,8 @@ llama.cpp :8080 + profilabhängige MCP-Server
Kleiner OpenAI-kompatibler Proxy (Python, nur Standardbibliothek) vor einem Kleiner OpenAI-kompatibler Proxy (Python, nur Standardbibliothek) vor einem
lokalen llama.cpp-Server. Er leitet normale OpenAI-Requests transparent lokalen llama.cpp-Server. Er leitet normale OpenAI-Requests transparent
weiter (Streaming, Tool Calls, JSON), schaltet zwischen drei festen weiter (Streaming, Tool Calls, JSON und Bilder), schaltet zwischen drei festen
llama.cpp-Profilen um, orchestriert lokale Bildgenerierung mit multimodalen llama.cpp-Profilen um, orchestriert lokale Bildgenerierung mit
FLUX.2 [klein] 4B Base (GPU-Hotswap: Qwen stoppen → FLUX laden → Bild FLUX.2 [klein] 4B Base (GPU-Hotswap: Qwen stoppen → FLUX laden → Bild
→ FLUX entladen → Qwen wiederherstellen) und stellt lokale deutsche → FLUX entladen → Qwen wiederherstellen) und stellt lokale deutsche
Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel). Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel).
@@ -84,7 +84,7 @@ Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel).
| Profil | Modell | Kontext | | Profil | Modell | Kontext |
|---|---|---| |---|---|---|
| `fast` | `qwen-fast` | 73728 | | `fast` | `qwen-fast` | 76800 |
| `medium` | `qwen-medium` | 94208 | | `medium` | `qwen-medium` | 94208 |
| `long` | `qwen-long` | 131072 | | `long` | `qwen-long` | 131072 |
@@ -105,7 +105,6 @@ Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel).
| `POST /v1/audio/transcriptions` | Deutsche Spracherkennung (whisper.cpp, OpenAI-kompatibel) | | `POST /v1/audio/transcriptions` | Deutsche Spracherkennung (whisper.cpp, OpenAI-kompatibel) |
| `GET /v1/audio/models` | Verfügbare Audio-Modelle (STT + TTS) | | `GET /v1/audio/models` | Verfügbare Audio-Modelle (STT + TTS) |
| `GET /v1/audio/voices` | Verfügbare TTS-Stimmen | | `GET /v1/audio/voices` | Verfügbare TTS-Stimmen |
| `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 Bis auf `GET /health` und `GET /ready` benötigen alle Endpunkte einen
@@ -243,41 +242,19 @@ VRAM-Check).
Das Venv liegt unter `/opt/mike-ai/ai-profile-router/venv/` und wird von Das Venv liegt unter `/opt/mike-ai/ai-profile-router/venv/` und wird von
`install.sh` automatisch angelegt/aktualisiert. `install.sh` automatisch angelegt/aktualisiert.
## Vision (Q3 "Augen") ## Integrierte Vision
Bilder in `POST /v1/chat/completions` werden automatisch analysiert, ohne Alle drei Qwen-Profile laden denselben BF16-Multimodalprojektor direkt beim
dass das Hauptmodell (Fast/Medium/Long) ein Vision-Modell braucht: Start. Der Projektor bleibt mit `--no-mmproj-offload` im System-RAM und
verbraucht dadurch keinen zusätzlichen VRAM. Bilder in
`POST /v1/chat/completions` werden nach Größen- und URL-Prüfung unverändert an
das aktive Qwen-Profil weitergereicht. Es gibt kein separates Q3-Modell, keinen
Vision-Port, keinen Analyse-Cache und keinen Modellwechsel mehr. Folgefragen
bleiben dadurch im normalen multimodalen Chatverlauf.
1. **Neues Bild** (in der letzten User-Message, noch nicht analysiert): Externe Bild-URLs sind standardmäßig gesperrt; Data-URLs dürfen dekodiert
Der Router entlädt das Hauptprofil, startet kurzzeitig einen höchstens 20 MiB groß sein. Die FLUX-Bildgenerierung bleibt ein separater
Q3-Vision-Server (llama.cpp + mmproj, Port `VISION_PORT`), analysiert GPU-Hotswap und ist von dieser Änderung nicht betroffen.
das Bild und stellt das Hauptprofil wieder her. Die Analyse wird im
internen Cache (keyed by Bild-Hash) gespeichert.
2. **Finale Antwort**: Das (wiederhergestellte) Hauptmodell erzeugt die
sichtbare Antwort. Die Vision-Analyse wird als interne User-Message
injiziert – sie erscheint **nie** als Assistant-Turn in Open WebUI.
3. **Folgefragen**: Open WebUI schickt den multimodalen Verlauf erneut.
Der Router ersetzt **alle** Bild-Parts durch die gecachte Analyse
(klar gekennzeichnet) und entfernt Base64/URLs komplett – das
Nicht-Vision-Hauptmodell bekommt also keine Bilddaten mehr
(kein `image input is not supported`). Bereits analysierte Bilder
triggern **keinen** neuen Hotswap (Cache-Treffer).
- **Zentrale GPU-Lock**: Vision und FLUX schließen sich gegenseitig aus
(kein gleichzeitiges Modell-Laden).
- **Fehlerbehandlung**: Bei jedem Fehler wird das Hauptprofil
wiederhergestellt; `finally` dient nur als Cleanup-Sicherung.
- **Test**: `POST /vision/test` mit `{"image_url": "...", "question": "..."}`
liefert die Analyse direkt (ohne finale Hauptmodell-Antwort).
### Verhalten während eines Vision-Jobs
- `GET /status` → `vision.phase` (`idle`, `stopping-main`,
`loading-vision`, `analyzing`, `unloading-vision`, `restoring-main`)
und `vision.analysis_cache_size`.
- `/v1/streams/lookup` antwortet fail-fast (`[]`), statt zu blockieren.
- Timing-Log: `Vision-Timing: main_unload=… vision_load=… vision_infer=…
vision_unload=… main_restore=… | Gesamt … s`.
## Sprachausgabe (XTTS-v2, CPU-only) ## Sprachausgabe (XTTS-v2, CPU-only)
@@ -549,18 +526,8 @@ Die Installation ist idempotent (Update = erneut ausführen).
| `TTS_WORKER_URL` | `http://127.0.0.1:8085` | TTS-Worker (Router-Seite) | | `TTS_WORKER_URL` | `http://127.0.0.1:8085` | TTS-Worker (Router-Seite) |
| `TTS_TIMEOUT` | `300` | Timeout pro Synthese (s) | | `TTS_TIMEOUT` | `300` | Timeout pro Synthese (s) |
| `TTS_CONNECT_TIMEOUT` | `5` | Connect-Timeout TTS-Worker (s) | | `TTS_CONNECT_TIMEOUT` | `5` | Connect-Timeout TTS-Worker (s) |
| `VISION_MODEL` | `/opt/mike-ai/models/qwen3.8-27b/Qwen3.8-27B-Q3_K_M.gguf` | Q3-Vision-Modell | | `CHAT_IMAGE_MAX_BYTES` | `20971520` | maximales dekodiertes Chatbild (20 MiB) |
| `VISION_MMPROJ` | `/opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf` | Vision-Projektor | | `CHAT_IMAGE_ALLOW_REMOTE_URLS` | `false` | externe Bild-URLs; standardmäßig SSRF-sicher aus |
| `VISION_CTX` | `32768` | Kontext des Vision-Servers |
| `VISION_PORT` | `8086` | Port des Vision-Servers (nur lokal) |
| `VISION_LOAD_TIMEOUT` | `300` | Warten auf Vision-Ready (s) |
| `VISION_INFER_TIMEOUT` | `300` | Timeout pro Vision-Inferenz (s) |
| `VISION_UNLOAD_TIMEOUT` | `120` | Warten auf VRAM-Freiheit (s) |
| `VISION_MAX_TOKENS` | `4096` | Max. Tokens der Vision-Analyse |
| `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 |
TTS-Worker (`mike-ai-xtts.service`): TTS-Worker (`mike-ai-xtts.service`):
+3 -13
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Mike AI Profile Router (OpenAI-kompatibler Proxy + Bild-Orchestrierung, Port 8081) Description=Mike AI Profile Router (OpenAI-kompatibler multimodaler Proxy, Port 8081)
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -31,18 +31,8 @@ Environment=IMAGE_VRAM_FREE_TIMEOUT=120
Environment=IMAGE_RETENTION_FILES=100 Environment=IMAGE_RETENTION_FILES=100
Environment=IMAGE_RETENTION_BYTES=5368709120 Environment=IMAGE_RETENTION_BYTES=5368709120
Environment=IMAGE_RETENTION_DAYS=30 Environment=IMAGE_RETENTION_DAYS=30
Environment=LLAMA_SERVER_BIN=/opt/mike-ai/llama.cpp/build/bin/llama-server Environment=CHAT_IMAGE_MAX_BYTES=20971520
Environment=VISION_MODEL=/opt/mike-ai/models/qwen3.8-27b/Qwen3.8-27B-Q3_K_M.gguf Environment=CHAT_IMAGE_ALLOW_REMOTE_URLS=false
Environment=VISION_MMPROJ=/opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf
Environment=VISION_CTX=32768
Environment=VISION_PORT=8086
Environment=VISION_LOAD_TIMEOUT=300
Environment=VISION_INFER_TIMEOUT=300
Environment=VISION_UNLOAD_TIMEOUT=120
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
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectHome=true ProtectHome=true
+1 -1
View File
@@ -1 +1 @@
ExecStart=/usr/bin/mock-llama --ctx-size 73728 ExecStart=/usr/bin/mock-llama --ctx-size 76800
@@ -1 +1 @@
ExecStart=/usr/bin/mock-llama --ctx-size 73728 ExecStart=/usr/bin/mock-llama --ctx-size 76800
+1 -1
View File
@@ -25,7 +25,7 @@ def current_ctx() -> int:
return int(line.split("--ctx-size")[1].split()[0]) return int(line.split("--ctx-size")[1].split()[0])
except (OSError, ValueError, IndexError): except (OSError, ValueError, IndexError):
pass pass
return 73728 return 76800
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
+4 -4
View File
@@ -124,7 +124,7 @@ import json,sys
d=json.load(sys.stdin) d=json.load(sys.stdin)
ids={m["id"]:m for m in d["data"]} ids={m["id"]:m for m in d["data"]}
assert set(ids)=={"qwen-fast","qwen-medium","qwen-long"}, ids assert set(ids)=={"qwen-fast","qwen-medium","qwen-long"}, ids
assert ids["qwen-fast"]["context_length"]==73728 assert ids["qwen-fast"]["context_length"]==76800
assert ids["qwen-medium"]["context_length"]==94208 assert ids["qwen-medium"]["context_length"]==94208
assert ids["qwen-long"]["context_length"]==131072 assert ids["qwen-long"]["context_length"]==131072
' && ok "drei virtuelle Modelle mit korrekten Context Windows" || bad "/v1/models" ' && ok "drei virtuelle Modelle mit korrekten Context Windows" || bad "/v1/models"
@@ -138,7 +138,7 @@ import json,sys
d=json.load(sys.stdin) d=json.load(sys.stdin)
assert d["current_profile"]=="fast", d assert d["current_profile"]=="fast", d
assert d["upstream"]["reachable"] is True, d assert d["upstream"]["reachable"] is True, d
assert d["upstream"]["ctx"]==73728, d assert d["upstream"]["ctx"]==76800, d
' && ok "Status zeigt Profil fast + erreichbares Upstream" || bad "/status" ' && ok "Status zeigt Profil fast + erreichbares Upstream" || bad "/status"
# --- 3. Normales Forwarding (non-streaming) ------------------------------------- # --- 3. Normales Forwarding (non-streaming) -------------------------------------
@@ -199,7 +199,7 @@ echo "$RESP" | python3 -m json.tool
echo "$RESP" | python3 -c ' echo "$RESP" | python3 -c '
import json,sys import json,sys
d=json.load(sys.stdin) d=json.load(sys.stdin)
assert d["profile"]=="fast" and d["context_length"]==73728, d assert d["profile"]=="fast" and d["context_length"]==76800, d
' && ok "Profil fast wieder aktiv" || bad "Profilwechsel fast" ' && ok "Profil fast wieder aktiv" || bad "Profilwechsel fast"
# --- 8. Virtuelles Modell triggert Profilwechsel ---------------------------------------- # --- 8. Virtuelles Modell triggert Profilwechsel ----------------------------------------
@@ -270,7 +270,7 @@ echo "$RESP" | python3 -m json.tool
echo "$RESP" | python3 -c ' echo "$RESP" | python3 -c '
import json,sys import json,sys
d=json.load(sys.stdin) d=json.load(sys.stdin)
assert d["profile"]=="fast" and d["model"]=="mock-model-73728", d assert d["profile"]=="fast" and d["model"]=="mock-model-76800", d
' && ok "Recovery: /fast wartet auf Upstream, dann Erfolg" || bad "Recovery" ' && ok "Recovery: /fast wartet auf Upstream, dann Erfolg" || bad "Recovery"
# --- 11. Bildgenerierung (Mock-Worker) ------------------------------------------------- # --- 11. Bildgenerierung (Mock-Worker) -------------------------------------------------
+31 -7
View File
@@ -23,7 +23,11 @@ from router_support import ( # noqa: E402
enforce_artifact_retention, enforce_artifact_retention,
load_profile_registry, load_profile_registry,
) )
from ai_profile_router import _normalize_vision_image # noqa: E402 from ai_profile_router import ( # noqa: E402
_normalize_chat_image,
_normalize_chat_images,
_request_has_image,
)
class AuthPolicyTests(unittest.TestCase): class AuthPolicyTests(unittest.TestCase):
@@ -47,9 +51,9 @@ class RuntimeStoreTests(unittest.TestCase):
def test_atomic_roundtrip_and_delete(self) -> None: def test_atomic_roundtrip_and_delete(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
store = RuntimeStore(str(Path(temp) / "state.json")) store = RuntimeStore(str(Path(temp) / "state.json"))
store.save(worker="vision", worker_pid=123, last_profile="fast") store.save(worker="image", worker_pid=123, last_profile="fast")
self.assertEqual(store.load()["worker_pid"], 123) self.assertEqual(store.load()["worker_pid"], 123)
store.clear_worker("vision") store.clear_worker("image")
state = store.load() state = store.load()
self.assertNotIn("worker", state) self.assertNotIn("worker", state)
self.assertEqual(state["last_profile"], "fast") self.assertEqual(state["last_profile"], "fast")
@@ -75,18 +79,38 @@ class ProfileRegistryTests(unittest.TestCase):
load_profile_registry("/definitely/missing/profiles.json") load_profile_registry("/definitely/missing/profiles.json")
class VisionInputTests(unittest.TestCase): class ChatImageInputTests(unittest.TestCase):
def test_small_png_data_url_is_accepted(self) -> None: def test_small_png_data_url_is_accepted(self) -> None:
value = "data:image/png;base64,iVBORw0KGgo=" value = "data:image/png;base64,iVBORw0KGgo="
self.assertEqual(_normalize_vision_image(value), value) self.assertEqual(_normalize_chat_image(value), value)
def test_remote_url_is_denied_by_default(self) -> None: def test_remote_url_is_denied_by_default(self) -> None:
with self.assertRaisesRegex(ValueError, "deaktiviert"): with self.assertRaisesRegex(ValueError, "deaktiviert"):
_normalize_vision_image("https://example.com/private.png") _normalize_chat_image("https://example.com/private.png")
def test_invalid_base64_is_rejected(self) -> None: def test_invalid_base64_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "Base64"): with self.assertRaisesRegex(ValueError, "Base64"):
_normalize_vision_image("data:image/png;base64,not!base64") _normalize_chat_image("data:image/png;base64,not!base64")
def test_multimodal_part_is_retained_for_direct_forwarding(self) -> None:
value = "data:image/png;base64,iVBORw0KGgo="
request = {
"model": "qwen-fast",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Was ist zu sehen?"},
{"type": "image_url", "image_url": {"url": value}},
],
}],
}
self.assertTrue(_request_has_image(request))
normalized = _normalize_chat_images(request)
self.assertEqual(
normalized["messages"][0]["content"][1]["image_url"]["url"],
value,
)
self.assertEqual(request, normalized)
class RetentionTests(unittest.TestCase): class RetentionTests(unittest.TestCase):
+10 -12
View File
@@ -36,7 +36,7 @@ Zielplattform.
### Aktives Fast-Profil ### Aktives Fast-Profil
- Qwen3.8-27B IQ4-MIX - Qwen3.8-27B IQ4-MIX
- Kontext 73.728 - Kontext 76.800
- vollständig auf CUDA0 - vollständig auf CUDA0
- Flash Attention - Flash Attention
- KV-Cache Q4_0 für K und V - KV-Cache Q4_0 für K und V
@@ -51,9 +51,9 @@ Zielplattform.
| Profil | Virtuelles Modell | Kontext | Besonderheit | | Profil | Virtuelles Modell | Kontext | Besonderheit |
|---|---|---:|---| |---|---|---:|---|
| Fast | `qwen-fast` | 73.728 | IQ4-MIX, MTP2, vollständig GPU | | Fast | `qwen-fast` | 76.800 | IQ4-MIX, MTP2, vollständig GPU, CPU-mmproj |
| Medium | `qwen-medium` | 94.208 | IQ4_XS Pure, ohne MTP | | Medium | `qwen-medium` | 94.208 | IQ4_XS Pure, ohne MTP, CPU-mmproj |
| Long | `qwen-long` | 131.072 | IQ4-MIX, MTP2, FFN-Blöcke 0–11 auf CPU | | Long | `qwen-long` | 131.072 | IQ4-MIX, MTP2, FFN 0–11 auf CPU, CPU-mmproj |
## Router ## Router
@@ -69,7 +69,7 @@ Der Router übernimmt:
- OpenAI-kompatibles Chat-Proxying und Streaming - OpenAI-kompatibles Chat-Proxying und Streaming
- virtuelle Modelle und automatische Profilumschaltung - virtuelle Modelle und automatische Profilumschaltung
- Tool Calls - Tool Calls
- Vision-Hotswap mit Cache für Folgefragen - direkte integrierte Vision in allen Qwen-Profilen
- FLUX-Hotswap zur Bildgenerierung - FLUX-Hotswap zur Bildgenerierung
- Whisper Speech-to-Text - Whisper Speech-to-Text
- XTTS Text-to-Speech - XTTS Text-to-Speech
@@ -79,15 +79,13 @@ Der Router übernimmt:
| Bereich | Referenz | | Bereich | Referenz |
|---|---| |---|---|
| Text-/Visionmodell | Qwen3.8-27B Q3_K_M | | Text-/Visionmodell | jeweils aktives Qwen3.8-27B-Profil |
| Projektor | BF16-mmproj | | Projektor | BF16-mmproj |
| Kontext | 32.768 | | Speicherort des Projektors | System-RAM (`--no-mmproj-offload`) |
| temporärer Port | 8086 | | Kontext | entspricht Fast/Medium/Long |
| maximale Ausgabe | 4.096 Tokens |
Vision wird nicht dauerhaft parallel geladen. Der Router entlädt das Vision ist Bestandteil jedes Profils. Der Router prüft Bildgröße und URL,
Textprofil, analysiert das Bild und stellt danach das ursprüngliche Profil leitet das Bild dann direkt weiter und führt keinen Modellwechsel mehr aus.
wieder her.
## Bildgenerierung ## Bildgenerierung
+5 -5
View File
@@ -21,7 +21,7 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
- [ ] llama.cpp entspricht dem festgelegten Commit - [ ] llama.cpp entspricht dem festgelegten Commit
- [ ] Modelldateien stimmen mit SHA256 überein - [ ] Modelldateien stimmen mit SHA256 überein
- [ ] Fast startet mit 73.728 Kontext - [ ] Fast startet mit 76.800 Kontext
- [ ] Medium startet mit 94.208 Kontext - [ ] Medium startet mit 94.208 Kontext
- [ ] Long startet mit 131.072 Kontext - [ ] Long startet mit 131.072 Kontext
- [ ] GPU-/CPU-Verteilung entspricht den Profilen - [ ] GPU-/CPU-Verteilung entspricht den Profilen
@@ -61,11 +61,11 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
## Phase E – Vision, Bild und Sprache ## Phase E – Vision, Bild und Sprache
- [ ] neues Bild löst genau einen Vision-Hotswap aus - [ ] neues Bild wird ohne Dienstneustart direkt vom aktiven Qwen analysiert
- [ ] Folgefrage verwendet Cache und keinen zweiten Hotswap - [ ] Folgefrage bleibt im multimodalen Verlauf und löst keinen Hotswap aus
- [ ] Bilddaten werden vor dem Textmodell sanitisiert - [ ] Bilddaten werden größen- und URL-validiert
- [ ] Remote-/private Bild-URL wird abgewiesen und übergroße Data-URL blockiert - [ ] Remote-/private Bild-URL wird abgewiesen und übergroße Data-URL blockiert
- [ ] Qwen-Profil wird nach Vision wiederhergestellt - [ ] llama.cpp-PID und aktives Profil bleiben bei Vision unverändert
- [ ] FLUX erzeugt Standard- und High-Bild - [ ] FLUX erzeugt Standard- und High-Bild
- [ ] Qwen-Profil wird nach FLUX wiederhergestellt - [ ] Qwen-Profil wird nach FLUX wiederhergestellt
- [ ] Whisper transkribiert deutsche und englische Testdatei - [ ] Whisper transkribiert deutsche und englische Testdatei
+2 -2
View File
@@ -4,7 +4,7 @@
- Qwen3.8-27B IQ4-MIX und das getestete MTP2-Profil - Qwen3.8-27B IQ4-MIX und das getestete MTP2-Profil
- IQ4_XS Pure für Medium - IQ4_XS Pure für Medium
- Q3-Vision-Modell und passender `mmproj` - BF16-`mmproj` für die integrierte Vision aller Qwen-Profile
- festgeschriebener llama.cpp-Commit - festgeschriebener llama.cpp-Commit
- Router, Whisper, XTTS und Websuche - Router, Whisper, XTTS und Websuche
- spezialisierte MCPs nach Sicherheitsprofil - spezialisierte MCPs nach Sicherheitsprofil
@@ -33,7 +33,7 @@
8. Medium und Long einzeln testen. 8. Medium und Long einzeln testen.
9. Router installieren und Profilwechsel testen. 9. Router installieren und Profilwechsel testen.
10. Web, HA, ARR und Unraid nacheinander hinzufügen. 10. Web, HA, ARR und Unraid nacheinander hinzufügen.
11. STT, TTS und Vision ergänzen. 11. STT, TTS und den Projektor für integrierte Vision ergänzen.
12. Standardbenchmark und Sicherheitsprüfung ausführen. 12. Standardbenchmark und Sicherheitsprüfung ausführen.
13. Erst danach Clients umstellen. 13. Erst danach Clients umstellen.
+1 -1
View File
@@ -4,7 +4,7 @@
| Profil | Virtuelles Modell | Kontext | Zweck | | Profil | Virtuelles Modell | Kontext | Zweck |
|---|---|---:|---| |---|---|---:|---|
| Fast | `qwen-fast` | 73.728 | Alltag, Agenten, hohe Geschwindigkeit | | Fast | `qwen-fast` | 76.800 | Alltag, Agenten, hohe Geschwindigkeit, integrierte Vision |
| Medium | `qwen-medium` | 94.208 | mehr Kontext, reine IQ4_XS-Variante | | Medium | `qwen-medium` | 94.208 | mehr Kontext, reine IQ4_XS-Variante |
| Long | `qwen-long` | 131.072 | lange Hermes-/MCP-Sitzungen | | Long | `qwen-long` | 131.072 | lange Hermes-/MCP-Sitzungen |
-1
View File
@@ -31,7 +31,6 @@ Pflichtrollen:
- Qwen Fast/Long IQ4-MIX - Qwen Fast/Long IQ4-MIX
- Qwen Medium IQ4_XS Pure - Qwen Medium IQ4_XS Pure
- Qwen Q3 Vision
- BF16 Vision-Projektor - BF16 Vision-Projektor
- Whisper large-v3-turbo - Whisper large-v3-turbo
- FLUX.2 klein - FLUX.2 klein
+1 -1
View File
@@ -16,7 +16,7 @@ nach Standardbenchmark, Tool-Calling-Test und Kontexttest übernommen.
- Fast und Long: Qwen3.8-27B IQ4-MIX mit MTP2 - Fast und Long: Qwen3.8-27B IQ4-MIX mit MTP2
- Medium: Qwen3.8-27B IQ4_XS Pure ohne MTP - Medium: Qwen3.8-27B IQ4_XS Pure ohne MTP
- Vision-Hotswap: Qwen3.8-27B Q3_K_M plus BF16-mmproj - Alle Profile: integrierte Vision mit demselben BF16-mmproj im System-RAM
Die Dateien selbst sind nicht Bestandteil des Repositories. Pfade und Hashes Die Dateien selbst sind nicht Bestandteil des Repositories. Pfade und Hashes
werden im lokalen Modellmanifest verwaltet. werden im lokalen Modellmanifest verwaltet.
+1 -7
View File
@@ -12,14 +12,8 @@ models:
file: qwen3.8-27b-IQ4_XS-pure.gguf file: qwen3.8-27b-IQ4_XS-pure.gguf
target: /opt/mike-ai/models/qwen3.8-27b-iq4-xs-pure/qwen3.8-27b-IQ4_XS-pure.gguf target: /opt/mike-ai/models/qwen3.8-27b-iq4-xs-pure/qwen3.8-27b-IQ4_XS-pure.gguf
sha256: "REPLACE_AFTER_VERIFICATION" sha256: "REPLACE_AFTER_VERIFICATION"
qwen_vision:
role: temporary-vision-model
source: "REPLACE_WITH_MODEL_REPOSITORY"
file: Qwen3.8-27B-Q3_K_M.gguf
target: /opt/mike-ai/models/qwen3.8-27b/Qwen3.8-27B-Q3_K_M.gguf
sha256: "REPLACE_AFTER_VERIFICATION"
qwen_vision_projector: qwen_vision_projector:
role: vision-projector role: integrated-vision-projector-for-all-qwen-profiles
source: "REPLACE_WITH_MODEL_REPOSITORY" source: "REPLACE_WITH_MODEL_REPOSITORY"
file: mmproj-BF16.gguf file: mmproj-BF16.gguf
target: /opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf target: /opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf
+2 -2
View File
@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Local AI llama.cpp - Qwen Long 128K MTP2 FFN12 CPU Description=Local AI llama.cpp - Qwen Long 128K MTP2 FFN12 CPU with CPU Vision
[Service] [Service]
ExecStart= ExecStart=
ExecStart=/opt/mike-ai/llama.cpp/build/bin/llama-server --model /opt/mike-ai/models/qwen3.8-27b-iq4-mix/Qwen3.8-27B-IQ4-MIX.gguf --alias qwen38-27b-iq4mix-128k-mtp2-ffn12 --ctx-size 131072 --flash-attn on --cache-type-k q4_0 --cache-type-v q4_0 --threads 6 --threads-batch 6 --batch-size 64 --ubatch-size 32 --parallel 1 --jinja --host 127.0.0.1 --port 8080 --metrics --fit off --n-gpu-layers all --override-tensor blk.([0-9]|1[0-1]).ffn_.*=CPU --no-mmap --temperature 0.2 --top-p 0.8 --top-k 20 --mcp-servers-config /etc/mike-ai/mcp-servers.json --device CUDA0 --split-mode none --spec-type draft-mtp --spec-draft-n-max 2 --spec-draft-type-k q4_0 --spec-draft-type-v q4_0 ExecStart=/opt/mike-ai/llama.cpp/build/bin/llama-server --model /opt/mike-ai/models/qwen3.8-27b-iq4-mix/Qwen3.8-27B-IQ4-MIX.gguf --mmproj /opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf --no-mmproj-offload --alias qwen38-27b-iq4mix-128k-mtp2-ffn12 --ctx-size 131072 --flash-attn on --cache-type-k q4_0 --cache-type-v q4_0 --threads 6 --threads-batch 6 --batch-size 64 --ubatch-size 32 --parallel 1 --jinja --host 127.0.0.1 --port 8080 --metrics --fit off --n-gpu-layers all --override-tensor blk.([0-9]|1[0-1]).ffn_.*=CPU --no-mmap --temperature 0.2 --top-p 0.8 --top-k 20 --mcp-servers-config /etc/mike-ai/mcp-servers.json --device CUDA0 --split-mode none --spec-type draft-mtp --spec-draft-n-max 2 --spec-draft-type-k q4_0 --spec-draft-type-v q4_0
+2 -2
View File
@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Local AI llama.cpp - Qwen Medium 92K IQ4_XS Pure Description=Local AI llama.cpp - Qwen Medium 92K IQ4_XS Pure with CPU Vision
[Service] [Service]
ExecStart= ExecStart=
ExecStart=/opt/mike-ai/llama.cpp/build/bin/llama-server --model /opt/mike-ai/models/qwen3.8-27b-iq4-xs-pure/qwen3.8-27b-IQ4_XS-pure.gguf --alias qwen38-27b-iq4xs-pure-92k --ctx-size 94208 --flash-attn on --cache-type-k q4_0 --cache-type-v q4_0 --threads 6 --threads-batch 6 --batch-size 64 --ubatch-size 32 --parallel 1 --jinja --reasoning auto --host 127.0.0.1 --port 8080 --metrics --fit off --n-gpu-layers all --no-mmap --temperature 0.2 --top-p 0.8 --top-k 20 --mcp-servers-config /etc/mike-ai/mcp-servers.json --device CUDA0 --split-mode none ExecStart=/opt/mike-ai/llama.cpp/build/bin/llama-server --model /opt/mike-ai/models/qwen3.8-27b-iq4-xs-pure/qwen3.8-27b-IQ4_XS-pure.gguf --mmproj /opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf --no-mmproj-offload --alias qwen38-27b-iq4xs-pure-92k --ctx-size 94208 --flash-attn on --cache-type-k q4_0 --cache-type-v q4_0 --threads 6 --threads-batch 6 --batch-size 64 --ubatch-size 32 --parallel 1 --jinja --reasoning auto --host 127.0.0.1 --port 8080 --metrics --fit off --n-gpu-layers all --no-mmap --temperature 0.2 --top-p 0.8 --top-k 20 --mcp-servers-config /etc/mike-ai/mcp-servers.json --device CUDA0 --split-mode none
+83 -605
View File
@@ -7,7 +7,7 @@ Profilen um:
Profil Kontext Profil Kontext
------ -------- ------ --------
fast 73728 fast 76800
medium 94208 medium 94208
long 131072 long 131072
@@ -39,14 +39,11 @@ das Modell wieder; danach wird das vorherige Qwen-Profil wiederher-
gestellt und erst dann geantwortet (try/finally – Qwen wird auch bei gestellt und erst dann geantwortet (try/finally – Qwen wird auch bei
Fehlgeschlagener Generierung wiederhergestellt). Fehlgeschlagener Generierung wiederhergestellt).
Vision-Orchestrierung (Q3 "Augen", temporär): Vision:
POST /v1/chat/completions mit Bild im letzten POST /v1/chat/completions mit Bild wird direkt an
User-Message → ein temporäres Q3-Vision-Modell das aktive multimodale Qwen-Profil weitergeleitet.
(llama-server + mmproj) wird geladen, analysiert Der Vision-Projektor ist Bestandteil des Profils;
das Bild und wird wieder entladen; das Hauptprofil es findet kein Modellwechsel statt.
wird immer wiederhergestellt (try/finally) und
erzeugt die Endantwort. Die Vision-Analyse bleibt
intern (kein sichtbarer Assistant-Turn).
Nur Python-Standardbibliothek. Logging nach stdout (journald). Nur Python-Standardbibliothek. Logging nach stdout (journald).
""" """
@@ -56,7 +53,6 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
import email import email
import hashlib
import ipaddress import ipaddress
import json import json
import logging import logging
@@ -73,7 +69,6 @@ import http.client
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
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
@@ -122,30 +117,12 @@ IMAGE_RETENTION_BYTES = int(os.environ.get(
"IMAGE_RETENTION_BYTES", str(5 * 1024 * 1024 * 1024))) "IMAGE_RETENTION_BYTES", str(5 * 1024 * 1024 * 1024)))
IMAGE_RETENTION_DAYS = int(os.environ.get("IMAGE_RETENTION_DAYS", "30")) IMAGE_RETENTION_DAYS = int(os.environ.get("IMAGE_RETENTION_DAYS", "30"))
# --- Vision-Orchestrierung (Q3 "Augen", temporär) --- # --- Multimodale Chat-Eingaben ---
# Chat-Requests mit Bild im letzten User-Message lösen einen # Bilder werden validiert und direkt an das aktive Qwen-Profil weitergeleitet.
# temporären Hotswap aus: Hauptprofil raus → Q3+mmproj rein → CHAT_IMAGE_MAX_BYTES = int(os.environ.get(
# Bild analysieren → Q3 raus → Hauptprofil rein (try/finally). "CHAT_IMAGE_MAX_BYTES", str(20 * 1024 * 1024)))
LLAMA_SERVER_BIN = os.environ.get( CHAT_IMAGE_ALLOW_REMOTE_URLS = os.environ.get(
"LLAMA_SERVER_BIN", "/opt/mike-ai/llama.cpp/build/bin/llama-server") "CHAT_IMAGE_ALLOW_REMOTE_URLS", "false").lower() in {"1", "true", "yes"}
VISION_MODEL = os.environ.get(
"VISION_MODEL", "/opt/mike-ai/models/qwen3.8-27b/Qwen3.8-27B-Q3_K_M.gguf")
VISION_MMPROJ = os.environ.get(
"VISION_MMPROJ", "/opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf")
VISION_CTX = int(os.environ.get("VISION_CTX", "32768"))
VISION_PORT = int(os.environ.get("VISION_PORT", "8086"))
VISION_ALIAS = "qwen38-27b-q3-vision"
VISION_LOAD_TIMEOUT = float(os.environ.get("VISION_LOAD_TIMEOUT", "300")) # s
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_MAX_TOKENS = int(os.environ.get("VISION_MAX_TOKENS", "4096"))
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", "/opt/mike-ai/ai-profile-router/vision_server.log")
# Erlaubte Auflösungen (Breite x Höhe). FLUX.2 klein ist für 1 MP # Erlaubte Auflösungen (Breite x Höhe). FLUX.2 klein ist für 1 MP
# ausgelegt; 1920x1088 (≈2 MP) wird zusätzlich unterstützt. # ausgelegt; 1920x1088 (≈2 MP) wird zusätzlich unterstützt.
@@ -247,43 +224,20 @@ IMAGE_PHASES = (
) )
class _VisionState:
"""Zustand der Vision-Orchestrierung (Status-Reporting + Analyse-Cache)."""
def __init__(self) -> None:
self.phase = "idle" # siehe VISION_PHASES unten
self.last_error: str | None = None
self.last_turnaround: float | None = None # s, letzter kompletter Swap
self.last_profile: str | None = None # Profil, das gesichert wurde
# Cache: stabiler Bild-Hash → Vision-Analyse-Text. Folgefragen im
# selben Chat (Open WebUI schickt den multimodalen Verlauf erneut)
# senden das Bild NICHT erneut durch Q3, sondern verwenden die
# gecachte Analyse. LRU-begrenzt auf VISION_CACHE_MAX Einträge.
self.analysis_cache: "OrderedDict[str, str]" = OrderedDict()
self.cache_lock = threading.Lock()
VISION_PHASES = (
"idle", "stopping-main", "loading-vision", "analyzing",
"unloading-vision", "restoring-main",
)
class _State: class _State:
"""Gemeinsamer, thread-sicherer Zustand. """Gemeinsamer, thread-sicherer Zustand.
lock : zentraler GPU-/Model-Lock. Wird von Profilwechsel, lock : zentraler GPU-/Model-Lock. Wird von Profilwechsel und
Image-Generation UND Vision-Swap gehalten → Bildgenerierung gehalten.
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).
""" """
def __init__(self) -> None: def __init__(self) -> None:
# RLock erlaubt atomare Abläufe aus Profilwahl + Vision + Chat-Lease, # RLock erlaubt atomare Abläufe aus Profilwahl + Chat-Lease,
# während die darunterliegenden Funktionen denselben Lock verwenden. # während die darunterliegenden Funktionen denselben Lock verwenden.
self.lock = threading.RLock() self.lock = threading.RLock()
self.switching: str | None = None self.switching: str | None = None
self.started = time.time() self.started = time.time()
self.image = _ImageState() self.image = _ImageState()
self.vision = _VisionState()
self.qwen_unavailable = True self.qwen_unavailable = True
self.active_chats = 0 self.active_chats = 0
self.avail_lock = threading.Lock() self.avail_lock = threading.Lock()
@@ -900,199 +854,43 @@ def _image_filename_ok(name: str) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vision-Orchestrierung (Q3 "Augen", temporär) # Multimodale Chat-Eingaben
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
VISION_ANALYST_PROMPT = ( _CHAT_IMAGE_DATA_TYPES = {
"Du bist ein reiner Bild- und Screenshot-Analyst. Du beantwortest die "
"Benutzerfrage NICHT selbst. Erstelle eine umfassende, von der aktuellen "
"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"
"1. SIEHTBARER TEXT: alle Texte wörtlich und vollständig, mit Anordnung\n"
"2. UI-ELEMENTE: Felder, Buttons, Menüs, Tabs, Dropdowns, Checkboxen – "
"mit Namen, Werten und Zustand (aktiv/inaktiv, gefüllt/leer, ausgewählt)\n"
"3. FEHLER- UND WARNMELDUNGEN: wörtlich, mit Farbe und Position\n"
"4. POSITIONEN UND BEZIEHUNGEN: räumliche Anordnung (oben/unten, "
"links/rechts, Reihenfolge)\n"
"5. ZUSTÄNDE: Statusanzeigen, Farben (rot/grün/gelb), Ladezustände\n"
"6. OBJEKTE: relevante Objekte und Beziehungen zwischen Elementen\n"
"7. WEITERES: alle übrigen sichtbaren Details\n\n"
"Regeln: Bei Screenshots hat Text- und UI-Genauigkeit Vorrang vor "
"schöner Beschreibung. Keine Interpretation, keine Vermutungen – nur "
"was sichtbar ist. Unleserliches als [unleserlich] markieren."
)
IMAGE_PLACEHOLDER = "[Bild angehänggt – siehe Vision-Analyse]"
class _VisionServer:
"""Temporärer llama-server (Q3 + mmproj) für die Bildanalyse."""
def __init__(self) -> None:
self.proc: subprocess.Popen | None = None
self._logf = None
def alive(self) -> bool:
return self.proc is not None and self.proc.poll() is None
def start(self) -> None:
if self.alive():
return
cmd = [
LLAMA_SERVER_BIN,
"--model", VISION_MODEL,
"--mmproj", VISION_MMPROJ,
"--alias", VISION_ALIAS,
"--ctx-size", str(VISION_CTX),
"--flash-attn", "on",
"--cache-type-k", "q4_0",
"--cache-type-v", "q4_0",
"--threads", "6",
"--threads-batch", "6",
"--batch-size", "64",
"--ubatch-size", "32",
"--parallel", "1",
"--jinja",
"--host", "127.0.0.1",
"--port", str(VISION_PORT),
"--metrics",
"--fit", "off",
"--n-gpu-layers", "all",
"--mmproj-offload",
"--no-mmap",
"--temperature", "0.2",
"--top-p", "0.8",
"--top-k", "20",
"--device", "CUDA0",
"--split-mode", "none",
]
self._logf = open(VISION_LOG, "ab")
self.proc = subprocess.Popen(
cmd, stdin=subprocess.DEVNULL,
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)",
self.proc.pid, VISION_PORT, VISION_CTX)
def wait_ready(self, deadline: float) -> None:
"""Wartet, bis der Vision-Server das Modell mit erwartetem ctx meldet."""
while True:
try:
conn = http.client.HTTPConnection("127.0.0.1", VISION_PORT,
timeout=3)
conn.request("GET", "/v1/models")
resp = conn.getresponse()
data = json.loads(resp.read())
conn.close()
models = data.get("data") or []
if models and (models[0].get("meta") or {}).get("n_ctx") == VISION_CTX:
return
except (OSError, ValueError):
pass
if not self.alive():
raise RuntimeError(
"Vision-Server-Prozess beendet sich während des Ladens "
f"(Details: {VISION_LOG})")
if time.monotonic() > deadline:
raise RuntimeError(
f"Vision-Server nach {VISION_LOAD_TIMEOUT:.0f} s nicht "
f"bereit (Details: {VISION_LOG})")
time.sleep(2)
def stop(self) -> None:
if self.proc is not None and self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=30)
except subprocess.TimeoutExpired:
log.warning("Vision-Server reagiert nicht auf SIGTERM – SIGKILL")
self.proc.kill()
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
if self._logf is not None:
try:
self._logf.close()
except OSError:
pass
self._logf = None
self.proc = None
RUNTIME.clear_worker("vision")
def _extract_last_user_image(data: dict) -> tuple[str | None, str]:
"""Liefert (image_url, question) aus der letzten User-Message.
Nur Bilder in der LETZTEN User-Message lösen eine Vision-Analyse aus.
Bilder in früheren Nachrichten sind bereits durch die vorherige
Antwort abgedeckt (Chat-Historie) und werden nur durch einen
Platzhalter ersetzt.
"""
messages = data.get("messages")
if not isinstance(messages, list):
return None, ""
for msg in reversed(messages):
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
return None, content
if isinstance(content, list):
image_url: str | None = None
texts: list[str] = []
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "image_url":
iu = part.get("image_url")
url = iu.get("url") if isinstance(iu, dict) else iu
if isinstance(url, str) and url:
image_url = url
elif (part.get("type") == "text"
and isinstance(part.get("text"), str)):
texts.append(part["text"])
question = " ".join(t.strip() for t in texts if t.strip())
return image_url, question
return None, ""
_VISION_DATA_TYPES = {
"image/jpeg", "image/png", "image/webp", "image/gif", "image/jpeg", "image/png", "image/webp", "image/gif",
} }
def _normalize_vision_image(image_url: str) -> str: def _normalize_chat_image(image_url: str) -> str:
"""Validate an image and return a bounded data URL for llama.cpp. """Validiert ein Bild und liefert eine begrenzte data-URL.
Remote fetches are off by default. If explicitly enabled, the router Remote-Downloads sind standardmäßig deaktiviert. Wenn sie ausdrücklich
downloads the file itself after rejecting local/private destinations and aktiviert werden, lädt der Router das Bild nach SSRF-Prüfung selbst und
hands llama.cpp a data URL. The model server therefore never receives an übergibt llama.cpp ausschließlich eine data-URL.
arbitrary user-controlled URL (SSRF protection).
""" """
if image_url.startswith("data:"): if image_url.startswith("data:"):
header, separator, payload = image_url.partition(",") header, separator, payload = image_url.partition(",")
match = re.fullmatch( match = re.fullmatch(
r"data:([a-zA-Z0-9.+-]+/[a-zA-Z0-9.+-]+);base64", header) 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: if (not separator or not match
or match.group(1).lower() not in _CHAT_IMAGE_DATA_TYPES):
raise ValueError("ungültige oder nicht unterstützte Bild-data-URL") raise ValueError("ungültige oder nicht unterstützte Bild-data-URL")
try: try:
raw = base64.b64decode(payload, validate=True) raw = base64.b64decode(payload, validate=True)
except (ValueError, binascii.Error): except (ValueError, binascii.Error):
raise ValueError("ungültige Base64-Bilddaten") from None raise ValueError("ungültige Base64-Bilddaten") from None
if not raw or len(raw) > VISION_MAX_IMAGE_BYTES: if not raw or len(raw) > CHAT_IMAGE_MAX_BYTES:
raise ValueError( raise ValueError(
f"Bildgröße außerhalb des Limits (max {VISION_MAX_IMAGE_BYTES} Bytes)") "Bildgröße außerhalb des Limits "
f"(max {CHAT_IMAGE_MAX_BYTES} Bytes)")
return image_url return image_url
parsed = urllib.parse.urlsplit(image_url) parsed = urllib.parse.urlsplit(image_url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname: 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") raise ValueError(
if not VISION_ALLOW_REMOTE_URLS: "Bild muss eine data-URL oder eine gültige HTTP(S)-URL sein")
if not CHAT_IMAGE_ALLOW_REMOTE_URLS:
raise ValueError( raise ValueError(
"Remote-Bild-URLs sind deaktiviert; Bild bitte als data-URL hochladen") "Remote-Bild-URLs sind deaktiviert; Bild bitte als data-URL hochladen")
try: try:
@@ -1100,7 +898,8 @@ def _normalize_vision_image(image_url: str) -> str:
parsed.hostname, parsed.hostname,
parsed.port or (443 if parsed.scheme == "https" else 80)) parsed.port or (443 if parsed.scheme == "https" else 80))
except socket.gaierror as exc: except socket.gaierror as exc:
raise ValueError(f"Bild-Host kann nicht aufgelöst werden: {exc}") from None raise ValueError(
f"Bild-Host kann nicht aufgelöst werden: {exc}") from None
for address in addresses: for address in addresses:
try: try:
ip = ipaddress.ip_address(address[4][0]) ip = ipaddress.ip_address(address[4][0])
@@ -1115,49 +914,50 @@ def _normalize_vision_image(image_url: str) -> str:
with urllib.request.urlopen(request, timeout=15) as response: with urllib.request.urlopen(request, timeout=15) as response:
final_url = urllib.parse.urlsplit(response.geturl()) final_url = urllib.parse.urlsplit(response.geturl())
if final_url.hostname != parsed.hostname: if final_url.hostname != parsed.hostname:
raise ValueError("Weiterleitungen zu einem anderen Bild-Host sind nicht erlaubt") raise ValueError(
"Weiterleitungen zu einem anderen Bild-Host sind nicht erlaubt")
content_type = response.headers.get_content_type().lower() content_type = response.headers.get_content_type().lower()
if content_type not in _VISION_DATA_TYPES: if content_type not in _CHAT_IMAGE_DATA_TYPES:
raise ValueError(f"Remote-Inhalt ist kein unterstütztes Bild ({content_type})") raise ValueError(
raw = response.read(VISION_MAX_IMAGE_BYTES + 1) "Remote-Inhalt ist kein unterstütztes Bild "
f"({content_type})")
raw = response.read(CHAT_IMAGE_MAX_BYTES + 1)
except urllib.error.URLError as exc: 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( raise ValueError(
f"Bildgröße außerhalb des Limits (max {VISION_MAX_IMAGE_BYTES} Bytes)") f"Remote-Bild kann nicht geladen werden: {exc}") from None
if not raw or len(raw) > CHAT_IMAGE_MAX_BYTES:
raise ValueError(
f"Bildgröße außerhalb des Limits (max {CHAT_IMAGE_MAX_BYTES} Bytes)")
return (f"data:{content_type};base64," return (f"data:{content_type};base64,"
+ base64.b64encode(raw).decode("ascii")) + base64.b64encode(raw).decode("ascii"))
def _image_hash(image_url: str) -> str: def _normalize_chat_images(data: dict) -> dict:
"""Stabiler Hash für ein Bild (Base64-Payload oder URL). """Validiert alle image_url-Parts, ohne sie aus dem Chat zu entfernen."""
out = json.loads(json.dumps(data))
Dasselbe Bild → derselbe Hash (unabhängig von Chat/Turn). Dient als messages = out.get("messages")
Schlüssel für den Vision-Analyse-Cache. if not isinstance(messages, list):
""" return out
if image_url.startswith("data:"): for message in messages:
payload = image_url.split(",", 1)[1] if "," in image_url else "" if not isinstance(message, dict):
try: continue
raw = base64.b64decode(payload) content = message.get("content")
return "img:" + hashlib.sha256(raw).hexdigest()[:32] if not isinstance(content, list):
except (ValueError, TypeError): continue
return "b64:" + hashlib.sha256(payload.encode()).hexdigest()[:32] for part in content:
return "url:" + hashlib.sha256(image_url.encode()).hexdigest()[:32] if not isinstance(part, dict) or part.get("type") != "image_url":
continue
image = part.get("image_url")
def _vision_cached_analysis(img_hash: str) -> str | None: if isinstance(image, dict):
"""Liefert die gecachte Vision-Analyse für einen Bild-Hash (oder None).""" url = image.get("url")
with STATE.vision.cache_lock: if not isinstance(url, str) or not url:
return STATE.vision.analysis_cache.get(img_hash) raise ValueError("image_url.url fehlt")
image["url"] = _normalize_chat_image(url)
elif isinstance(image, str) and image:
def _vision_store_analysis(img_hash: str, analysis: str) -> None: part["image_url"] = _normalize_chat_image(image)
"""Speichert eine Vision-Analyse im Cache (LRU-begrenzt).""" else:
with STATE.vision.cache_lock: raise ValueError("image_url fehlt")
STATE.vision.analysis_cache[img_hash] = analysis return out
STATE.vision.analysis_cache.move_to_end(img_hash)
while len(STATE.vision.analysis_cache) > VISION_CACHE_MAX:
STATE.vision.analysis_cache.popitem(last=False)
def _request_has_image(data: dict) -> bool: def _request_has_image(data: dict) -> bool:
@@ -1165,260 +965,12 @@ def _request_has_image(data: dict) -> bool:
messages = data.get("messages") messages = data.get("messages")
if not isinstance(messages, list): if not isinstance(messages, list):
return False return False
for msg in messages: return any(
if not isinstance(msg, dict): isinstance(part, dict) and part.get("type") == "image_url"
continue for message in messages if isinstance(message, dict)
content = msg.get("content") for part in (message.get("content")
if isinstance(content, list): if isinstance(message.get("content"), list) else [])
for part in content: )
if isinstance(part, dict) and part.get("type") == "image_url":
return True
return False
def _sanitize_for_main_model(data: dict) -> dict:
"""Erzeugt eine sanisierte Kopie des Requests für das Hauptmodell.
Das Hauptmodell (Fast/Medium/Long) hat KEIN Vision-Modell. Deshalb
werden alle Bild-Parts (image_url) durch den zu diesem Bild erzeugten
Vision-Analyse-Text (aus dem Cache) ersetzt:
- Bilddaten (Base64/URL) werden vollständig entfernt.
- Der ursprüngliche Text des Users bleibt erhalten.
- Die Analyse wird klar gekennzeichnet in denselben Turn eingesetzt.
Beispiel:
user: [image_url, "Was ist hier falsch?"]
→ user: "Was ist hier falsch?\n\n[Vision-Analyse des hochgeladenen
Bildes: ...]"
Wird bei JEDER Folgefrage erneut angewendet, weil Open WebUI den
ursprünglichen multimodalen Verlauf wieder mitsendet.
"""
out = json.loads(json.dumps(data))
messages = out.get("messages")
if not isinstance(messages, list):
return out
for msg in messages:
if not isinstance(msg, dict):
continue
content = msg.get("content")
if not isinstance(content, list):
continue
texts: list[str] = []
had_image = False
for part in content:
if isinstance(part, dict) and part.get("type") == "image_url":
had_image = True
iu = part.get("image_url")
url = iu.get("url") if isinstance(iu, dict) else iu
analysis = None
if isinstance(url, str) and url:
analysis = _vision_cached_analysis(_image_hash(url))
if analysis:
texts.append("[Vision-Analyse des hochgeladenen Bildes: "
+ analysis + "]")
else:
texts.append(IMAGE_PLACEHOLDER)
elif (isinstance(part, dict) and part.get("type") == "text"
and isinstance(part.get("text"), str)):
texts.append(part["text"])
# andere Part-Typen (z. B. audio) werden verworfen
if had_image:
msg["content"] = "\n\n".join(t for t in texts if t.strip())
return out
def _vision_analyze(image_url: str, question: str) -> str:
"""Sendet Bild + Frage an den Vision-Server, liefert den Analysen-Text."""
payload = {
"model": VISION_ALIAS,
"messages": [
{"role": "system", "content": VISION_ANALYST_PROMPT},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text",
"text": ("Aktuelle Benutzerfrage nur als zusätzlicher "
"Kontext, ohne die Analyse darauf zu beschränken: "
+ (question or "(keine Frage)")
+ "\n\nErstelle jetzt eine vollständige, "
"fragenunabhängige Vision-Analyse.")},
]},
],
"max_tokens": VISION_MAX_TOKENS,
"temperature": 0.1,
"stream": False,
}
body = json.dumps(payload).encode()
# timeout=VISION_INFER_TIMEOUT gilt für Connect UND Read. (Nicht
# conn.sock.settimeout() – vor connect() ist conn.sock noch None.)
conn = http.client.HTTPConnection("127.0.0.1", VISION_PORT,
timeout=VISION_INFER_TIMEOUT)
conn.request("POST", "/v1/chat/completions", body=body,
headers={"Content-Type": "application/json"})
resp = conn.getresponse()
raw = resp.read()
conn.close()
try:
data = json.loads(raw)
except ValueError:
raise RuntimeError(
f"Vision-Inferenz: ungültige Antwort (HTTP {resp.status})")
if resp.status != 200:
msg = (data.get("error") or {}).get("message", str(data)) \
if isinstance(data, dict) else str(data)
raise RuntimeError(f"Vision-Inferenz fehlgeschlagen ({resp.status}): {msg}")
choices = data.get("choices") or []
if not choices:
raise RuntimeError("Vision-Inferenz: leere Antwort")
content = (choices[0].get("message") or {}).get("content")
if not isinstance(content, str) or not content.strip():
raise RuntimeError("Vision-Inferenz: leere Analyse")
return content.strip()
def _vision_swap(data: dict) -> str:
"""Kompletter Vision-Hotswap (Q3 "Augen").
Hält den zentralen GPU-Lock (gegenseitiger Ausschluss mit
Profilwechsel und FLUX). Normaler Ablauf (explizit, Schritt für
Schritt – die Wiederherstellung des Hauptprofils erfolgt erst NACH
abgeschlossener Vision-Inferenz):
1. Hauptprofil entladen (VRAM freigeben)
2. Q3-Vision-Server starten und auf Ready warten
3. Bild direkt an 127.0.0.1:VISION_PORT analysieren (Inferenz)
4. Q3-Vision-Server stoppen
5. Hauptprofil wiederherstellen
finally dient NUR als Fehler-/Cleanup-Sicherung (Q3 stoppen,
Hauptprofil retten, Phase zurücksetzen, Timing loggen) – nicht als
normaler Ablauf.
Liefert den Analysen-Text. Die Analyse wird zusätzlich im
Vision-Cache (keyed by Bild-Hash) gespeichert, damit Folgefragen das
Bild nicht erneut durch Q3 schicken (siehe _sanitize_for_main_model).
"""
vis = STATE.vision
profile = current_profile()
if profile is None:
raise RuntimeError("kein aktives Qwen-Profil (override.conf?)")
image_url, question = _extract_last_user_image(data)
if not image_url:
raise RuntimeError("kein Bild im Request")
img_hash = _image_hash(image_url)
safe_image_url = _normalize_vision_image(image_url)
vis.last_profile = profile
vis.last_error = None
t_total = time.monotonic()
timings: dict[str, float] = {}
with STATE.lock:
if vis.phase != "idle":
raise RuntimeError(f"Vision-Analyse läuft ({vis.phase})")
# Qwen wird gestoppt → für Chats nicht verfügbar (die warten).
_set_qwen_unavailable(True)
vision = _VisionServer()
analysis: str | None = None
main_restored = False
try:
_wait_chats_drained()
# 1) Hauptprofil entladen (VRAM freigeben).
vis.phase = "stopping-main"
RUNTIME.save(last_profile=profile, phase=vis.phase)
t0 = time.monotonic()
proc = subprocess.run([SYSTEMCTL_BIN, "stop", LLAMA_SERVICE],
stdin=subprocess.DEVNULL,
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)
timings["main_unload"] = time.monotonic() - t0
log.info("Vision: Hauptprofil %s entladen (%.1f s)",
profile, timings["main_unload"])
# 2) Q3-Vision-Server starten und auf Ready warten.
vis.phase = "loading-vision"
t0 = time.monotonic()
vision.start()
vision.wait_ready(time.monotonic() + VISION_LOAD_TIMEOUT)
timings["vision_load"] = time.monotonic() - t0
log.info("Vision: Q3-Vision-Modell geladen (%.1f s)",
timings["vision_load"])
# 3) Bild direkt an den Vision-Server analysieren.
vis.phase = "analyzing"
t0 = time.monotonic()
log.info("Vision: Inferenz gestartet (127.0.0.1:%d)", VISION_PORT)
analysis = _vision_analyze(safe_image_url, question)
timings["vision_infer"] = time.monotonic() - t0
log.info("Vision: Inferenz abgeschlossen (%.1f s, %d Zeichen)",
timings["vision_infer"], len(analysis))
_vision_store_analysis(img_hash, analysis)
log.info("Vision: Analyse im Cache gespeichert (Hash %s…)",
img_hash[:16])
# 4) Q3-Vision-Server stoppen.
vis.phase = "unloading-vision"
t0 = time.monotonic()
vision.stop()
try:
_wait_vram_free(timeout=VISION_UNLOAD_TIMEOUT)
except RuntimeError as e:
log.warning("Vision VRAM-Check: %s (fahre mit Restore fort)", e)
timings["vision_unload"] = time.monotonic() - t0
log.info("Vision: Q3 gestoppt (%.1f s)", timings["vision_unload"])
# 5) Hauptprofil wiederherstellen (erst NACH der Inferenz).
vis.phase = "restoring-main"
t0 = time.monotonic()
log.info("Vision: Hauptprofil %s wird wiederhergestellt", profile)
_restore_qwen(profile)
timings["main_restore"] = time.monotonic() - t0
log.info("Vision: Hauptprofil %s wiederhergestellt (%.1f s)",
profile, timings["main_restore"])
main_restored = True
_set_qwen_unavailable(False)
except Exception as e:
# Fehler-/Cleanup-Pfad: Q3 stoppen, Hauptprofil retten.
log.error("Vision-Fehler in Phase %s: %s", vis.phase, e)
vis.last_error = str(e)
if vision.alive():
try:
vision.stop()
log.info("Vision: Q3 gestoppt (Cleanup nach Fehler)")
except Exception:
log.exception("Vision: Q3-Cleanup fehlgeschlagen")
if not main_restored:
try:
_restore_qwen(profile)
_set_qwen_unavailable(False)
log.info("Vision: Hauptprofil %s wiederhergestellt "
"(Cleanup nach Fehler)", profile)
except Exception as e2:
vis.last_error = (f"Qwen-Wiederherstellung "
f"fehlgeschlagen: {e2}")
log.error("Vision: %s", vis.last_error)
# qwen_unavailable bleibt True (Qwen ist down).
raise
finally:
# NUR Cleanup: Phase zurücksetzen, Timing loggen.
vis.phase = "idle"
vis.last_turnaround = round(time.monotonic() - t_total, 1)
log.info("Vision-Timing: %s | Gesamt %.1f s",
" ".join(f"{k}={v:.1f}s" for k, v in timings.items()),
vis.last_turnaround)
if analysis is None:
# Defensive Absicherung (der except-Pfad wirft immer weiter).
raise RuntimeError(
f"Vision-Analyse fehlgeschlagen: "
f"{vis.last_error or 'unbekannter Fehler'}")
return analysis
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1494,8 +1046,6 @@ class Handler(BaseHTTPRequestHandler):
self._speech() self._speech()
elif path == "/v1/audio/transcriptions" and self.command == "POST": elif path == "/v1/audio/transcriptions" and self.command == "POST":
self._transcribe() self._transcribe()
elif path == "/vision/test" and self.command == "POST":
self._vision_test()
elif path == "/images" and self.command == "GET": elif path == "/images" and self.command == "GET":
self._images_list() self._images_list()
elif path.startswith("/images/") and self.command == "GET": elif path.startswith("/images/") and self.command == "GET":
@@ -1694,13 +1244,6 @@ class Handler(BaseHTTPRequestHandler):
"last_seconds": img.last_seconds, "last_seconds": img.last_seconds,
"last_error": img.last_error, "last_error": img.last_error,
}, },
"vision": {
"phase": STATE.vision.phase,
"last_error": STATE.vision.last_error,
"last_profile": STATE.vision.last_profile,
"last_turnaround_seconds": STATE.vision.last_turnaround,
"analysis_cache_size": len(STATE.vision.analysis_cache),
},
"tts": tts_status(), "tts": tts_status(),
"stt": stt_status(), "stt": stt_status(),
} }
@@ -2128,63 +1671,6 @@ class Handler(BaseHTTPRequestHandler):
"model": up.get("model"), "model": up.get("model"),
}) })
# ---------- Interner Vision-Test ----------
def _vision_test(self) -> None:
"""Interner Vision-Test: führt den kompletten Q3-Hotswap durch und
liefert die Analyse + Timing (ohne finale Hauptmodell-Inferenz).
Body: {"image_url": "data:image/png;base64,..." | "http://...",
"question": "optional"}
"""
try:
body = self._read_body()
except ValueError as e:
self._send_error(400, str(e),
"invalid_request_error", "invalid_body")
return
try:
req = json.loads(body) if body else {}
except ValueError:
self._send_error(400, "ungültiges JSON",
"invalid_request_error", "invalid_body")
return
if not isinstance(req, dict):
self._send_error(400, "Body muss ein JSON-Objekt sein",
"invalid_request_error", "invalid_body")
return
image_url = req.get("image_url")
if not isinstance(image_url, str) or not image_url:
self._send_error(400, "image_url fehlt (data-URL oder http-URL)",
"invalid_request_error", "missing_image")
return
question = req.get("question") or ""
# Request bauen, der den Vision-Pfad triggert.
data = {
"model": "qwen-medium",
"messages": [
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text",
"text": question or "Analysiere das Bild."},
]},
],
}
self.timeout = None # Vision-Swap kann Minuten dauern
try:
analysis = _vision_swap(data)
except (ValueError, RuntimeError) as e:
self._send_error(502, str(e), "server_error", "vision_failed")
return
vis = STATE.vision
self._send_json(200, {
"status": "ok",
"profile": vis.last_profile,
"turnaround_seconds": vis.last_turnaround,
"analysis_chars": len(analysis),
"analysis": analysis,
})
# ---------- Transparentes Forwarding ---------- # ---------- Transparentes Forwarding ----------
def _forward(self) -> None: def _forward(self) -> None:
@@ -2197,8 +1683,8 @@ class Handler(BaseHTTPRequestHandler):
return return
# /v1/streams/lookup (Open-WebUI-Stream-Recovery): darf NIEMALS auf # /v1/streams/lookup (Open-WebUI-Stream-Recovery): darf NIEMALS auf
# die Qwen-Wiederherstellung warten (während Vision-Hotswap, # die Qwen-Wiederherstellung warten (während Profilwechsel oder
# Profilwechsel oder Image-Job ist Qwen down). Wenn Qwen down ist, # Image-Job ist Qwen down). Wenn Qwen down ist,
# gibt es per Definition keine aktiven Streams → sofortige lokale # gibt es per Definition keine aktiven Streams → sofortige lokale
# Antwort []. Ansonsten normal an llama.cpp weiterleiten. # Antwort []. Ansonsten normal an llama.cpp weiterleiten.
if path == "/v1/streams/lookup": if path == "/v1/streams/lookup":
@@ -2276,24 +1762,17 @@ class Handler(BaseHTTPRequestHandler):
def _chat_proxy(self, body: bytes | None, data: dict, def _chat_proxy(self, body: bytes | None, data: dict,
profile: str | None) -> None: profile: str | None) -> None:
"""Vision-Vorbereitung, Profilwahl und Chat-Lease als eine Transaktion.""" """Bildvalidierung, Profilwahl und Chat-Lease als eine Transaktion."""
lease_acquired = False lease_acquired = False
try: try:
with STATE.lock: with STATE.lock:
if profile is not None: if profile is not None:
switch_profile(profile, implicit=True) 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): if _request_has_image(data):
data = _sanitize_for_main_model(data) data = _normalize_chat_images(data)
log.info("Vision: finale Hauptmodell-Inferenz gestartet") log.info("Vision: Bild wird direkt an das aktive "
"multimodale Qwen-Profil weitergeleitet")
up = upstream_status() up = upstream_status()
if not up["reachable"] or not up.get("model"): if not up["reachable"] or not up.get("model"):
@@ -2425,9 +1904,8 @@ def _startup_reconcile() -> None:
"""Reconcile persisted worker/model state before accepting requests.""" """Reconcile persisted worker/model state before accepting requests."""
previous = RUNTIME.load() previous = RUNTIME.load()
worker = previous.get("worker") worker = previous.get("worker")
if worker in {"image", "vision"}: if worker == "image":
markers = ([IMAGE_WORKER] if worker == "image" else markers = [IMAGE_WORKER]
[VISION_ALIAS, VISION_MODEL, str(VISION_PORT)])
terminated = terminate_recorded_worker( terminated = terminate_recorded_worker(
previous, markers, lambda msg: log.warning("Recovery: %s", msg)) previous, markers, lambda msg: log.warning("Recovery: %s", msg))
if terminated: if terminated:
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"profiles": { "profiles": {
"fast": { "fast": {
"context": 73728, "context": 76800,
"model_alias": "qwen38-27b-iq4mix-72k-mtp2" "model_alias": "qwen38-27b-iq4mix-76k-mtp2-vision"
}, },
"medium": { "medium": {
"context": 94208, "context": 94208,
+1 -1
View File
@@ -33,7 +33,7 @@ def load_profile_registry(path: str | None) -> dict[str, dict]:
""" """
fallback = { fallback = {
"fast": {"context": 73728, "model_alias": None}, "fast": {"context": 76800, "model_alias": None},
"medium": {"context": 94208, "model_alias": None}, "medium": {"context": 94208, "model_alias": None},
"long": {"context": 131072, "model_alias": None}, "long": {"context": 131072, "model_alias": None},
} }