diff --git a/README.md b/README.md index 4a94bf4..ffffb39 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ Clients (Open WebUI, Hermes, Apps) | v AI Profile Router :8081 - |-- qwen-fast (72K, maximale Geschwindigkeit) + |-- qwen-fast (76.8K, maximale Geschwindigkeit) |-- qwen-medium (92K, reines IQ4_XS) |-- qwen-long (128K, CPU-Offload) - |-- Vision-Hotswap + |-- integrierte Qwen-Vision (kein Hotswap) |-- lokale Bildgenerierung |-- Whisper STT `-- XTTS TTS @@ -61,8 +61,8 @@ llama.cpp :8080 + profilabhängige MCP-Server Kleiner OpenAI-kompatibler Proxy (Python, nur Standardbibliothek) vor einem lokalen llama.cpp-Server. Er leitet normale OpenAI-Requests transparent -weiter (Streaming, Tool Calls, JSON), schaltet zwischen drei festen -llama.cpp-Profilen um, orchestriert lokale Bildgenerierung mit +weiter (Streaming, Tool Calls, JSON und Bilder), schaltet zwischen drei festen +multimodalen llama.cpp-Profilen um, orchestriert lokale Bildgenerierung mit FLUX.2 [klein] 4B Base (GPU-Hotswap: Qwen stoppen → FLUX laden → Bild → FLUX entladen → Qwen wiederherstellen) und stellt lokale deutsche Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel). @@ -84,7 +84,7 @@ Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel). | Profil | Modell | Kontext | |---|---|---| -| `fast` | `qwen-fast` | 73728 | +| `fast` | `qwen-fast` | 76800 | | `medium` | `qwen-medium` | 94208 | | `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) | | `GET /v1/audio/models` | Verfügbare Audio-Modelle (STT + TTS) | | `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 | 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 `install.sh` automatisch angelegt/aktualisiert. -## Vision (Q3 "Augen") +## Integrierte Vision -Bilder in `POST /v1/chat/completions` werden automatisch analysiert, ohne -dass das Hauptmodell (Fast/Medium/Long) ein Vision-Modell braucht: +Alle drei Qwen-Profile laden denselben BF16-Multimodalprojektor direkt beim +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): - Der Router entlädt das Hauptprofil, startet kurzzeitig einen - Q3-Vision-Server (llama.cpp + mmproj, Port `VISION_PORT`), analysiert - 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`. +Externe Bild-URLs sind standardmäßig gesperrt; Data-URLs dürfen dekodiert +höchstens 20 MiB groß sein. Die FLUX-Bildgenerierung bleibt ein separater +GPU-Hotswap und ist von dieser Änderung nicht betroffen. ## 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_TIMEOUT` | `300` | Timeout pro Synthese (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 | -| `VISION_MMPROJ` | `/opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf` | Vision-Projektor | -| `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 | +| `CHAT_IMAGE_MAX_BYTES` | `20971520` | maximales dekodiertes Chatbild (20 MiB) | +| `CHAT_IMAGE_ALLOW_REMOTE_URLS` | `false` | externe Bild-URLs; standardmäßig SSRF-sicher aus | TTS-Worker (`mike-ai-xtts.service`): diff --git a/deploy/mike-ai-profile-router.service b/deploy/mike-ai-profile-router.service index e897f3e..e7bed9c 100644 --- a/deploy/mike-ai-profile-router.service +++ b/deploy/mike-ai-profile-router.service @@ -1,5 +1,5 @@ [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 Wants=network-online.target @@ -31,18 +31,8 @@ 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=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_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 +Environment=CHAT_IMAGE_MAX_BYTES=20971520 +Environment=CHAT_IMAGE_ALLOW_REMOTE_URLS=false NoNewPrivileges=true PrivateTmp=true ProtectHome=true diff --git a/dev/fake-profile-dir/override.conf b/dev/fake-profile-dir/override.conf index ff17986..d6675ff 100644 --- a/dev/fake-profile-dir/override.conf +++ b/dev/fake-profile-dir/override.conf @@ -1 +1 @@ -ExecStart=/usr/bin/mock-llama --ctx-size 73728 +ExecStart=/usr/bin/mock-llama --ctx-size 76800 diff --git a/dev/fake-profile-dir/profile-fast.conf.disabled b/dev/fake-profile-dir/profile-fast.conf.disabled index ff17986..d6675ff 100644 --- a/dev/fake-profile-dir/profile-fast.conf.disabled +++ b/dev/fake-profile-dir/profile-fast.conf.disabled @@ -1 +1 @@ -ExecStart=/usr/bin/mock-llama --ctx-size 73728 +ExecStart=/usr/bin/mock-llama --ctx-size 76800 diff --git a/dev/mock_upstream.py b/dev/mock_upstream.py index 76f4ad9..19e0eb9 100755 --- a/dev/mock_upstream.py +++ b/dev/mock_upstream.py @@ -25,7 +25,7 @@ def current_ctx() -> int: return int(line.split("--ctx-size")[1].split()[0]) except (OSError, ValueError, IndexError): pass - return 73728 + return 76800 class Handler(BaseHTTPRequestHandler): diff --git a/dev/test_local.sh b/dev/test_local.sh index 2a9a81c..3f217a5 100755 --- a/dev/test_local.sh +++ b/dev/test_local.sh @@ -124,7 +124,7 @@ import json,sys d=json.load(sys.stdin) ids={m["id"]:m for m in d["data"]} 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-long"]["context_length"]==131072 ' && ok "drei virtuelle Modelle mit korrekten Context Windows" || bad "/v1/models" @@ -138,7 +138,7 @@ import json,sys d=json.load(sys.stdin) assert d["current_profile"]=="fast", 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" # --- 3. Normales Forwarding (non-streaming) ------------------------------------- @@ -199,7 +199,7 @@ echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys 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" # --- 8. Virtuelles Modell triggert Profilwechsel ---------------------------------------- @@ -270,7 +270,7 @@ echo "$RESP" | python3 -m json.tool echo "$RESP" | python3 -c ' import json,sys 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" # --- 11. Bildgenerierung (Mock-Worker) ------------------------------------------------- diff --git a/dev/test_router_support.py b/dev/test_router_support.py index 7fb0bd2..afafda2 100644 --- a/dev/test_router_support.py +++ b/dev/test_router_support.py @@ -23,7 +23,11 @@ from router_support import ( # noqa: E402 enforce_artifact_retention, 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): @@ -47,9 +51,9 @@ 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") + store.save(worker="image", worker_pid=123, last_profile="fast") self.assertEqual(store.load()["worker_pid"], 123) - store.clear_worker("vision") + store.clear_worker("image") state = store.load() self.assertNotIn("worker", state) self.assertEqual(state["last_profile"], "fast") @@ -75,18 +79,38 @@ class ProfileRegistryTests(unittest.TestCase): 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: 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: 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: 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): diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index 10789f8..0a5ac4c 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -36,7 +36,7 @@ Zielplattform. ### Aktives Fast-Profil - Qwen3.8-27B IQ4-MIX -- Kontext 73.728 +- Kontext 76.800 - vollständig auf CUDA0 - Flash Attention - KV-Cache Q4_0 für K und V @@ -51,9 +51,9 @@ Zielplattform. | Profil | Virtuelles Modell | Kontext | Besonderheit | |---|---|---:|---| -| Fast | `qwen-fast` | 73.728 | IQ4-MIX, MTP2, vollständig GPU | -| Medium | `qwen-medium` | 94.208 | IQ4_XS Pure, ohne MTP | -| Long | `qwen-long` | 131.072 | IQ4-MIX, MTP2, FFN-Blöcke 0–11 auf CPU | +| Fast | `qwen-fast` | 76.800 | IQ4-MIX, MTP2, vollständig GPU, CPU-mmproj | +| Medium | `qwen-medium` | 94.208 | IQ4_XS Pure, ohne MTP, CPU-mmproj | +| Long | `qwen-long` | 131.072 | IQ4-MIX, MTP2, FFN 0–11 auf CPU, CPU-mmproj | ## Router @@ -69,7 +69,7 @@ Der Router übernimmt: - OpenAI-kompatibles Chat-Proxying und Streaming - virtuelle Modelle und automatische Profilumschaltung - Tool Calls -- Vision-Hotswap mit Cache für Folgefragen +- direkte integrierte Vision in allen Qwen-Profilen - FLUX-Hotswap zur Bildgenerierung - Whisper Speech-to-Text - XTTS Text-to-Speech @@ -79,15 +79,13 @@ Der Router übernimmt: | Bereich | Referenz | |---|---| -| Text-/Visionmodell | Qwen3.8-27B Q3_K_M | +| Text-/Visionmodell | jeweils aktives Qwen3.8-27B-Profil | | Projektor | BF16-mmproj | -| Kontext | 32.768 | -| temporärer Port | 8086 | -| maximale Ausgabe | 4.096 Tokens | +| Speicherort des Projektors | System-RAM (`--no-mmproj-offload`) | +| Kontext | entspricht Fast/Medium/Long | -Vision wird nicht dauerhaft parallel geladen. Der Router entlädt das -Textprofil, analysiert das Bild und stellt danach das ursprüngliche Profil -wieder her. +Vision ist Bestandteil jedes Profils. Der Router prüft Bildgröße und URL, +leitet das Bild dann direkt weiter und führt keinen Modellwechsel mehr aus. ## Bildgenerierung diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md index a8a7380..c1b2961 100644 --- a/docs/DISASTER_RECOVERY.md +++ b/docs/DISASTER_RECOVERY.md @@ -21,7 +21,7 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] llama.cpp entspricht dem festgelegten Commit - [ ] Modelldateien stimmen mit SHA256 überein -- [ ] Fast startet mit 73.728 Kontext +- [ ] Fast startet mit 76.800 Kontext - [ ] Medium startet mit 94.208 Kontext - [ ] Long startet mit 131.072 Kontext - [ ] GPU-/CPU-Verteilung entspricht den Profilen @@ -61,11 +61,11 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. ## Phase E – Vision, Bild und Sprache -- [ ] neues Bild löst genau einen Vision-Hotswap aus -- [ ] Folgefrage verwendet Cache und keinen zweiten Hotswap -- [ ] Bilddaten werden vor dem Textmodell sanitisiert +- [ ] neues Bild wird ohne Dienstneustart direkt vom aktiven Qwen analysiert +- [ ] Folgefrage bleibt im multimodalen Verlauf und löst keinen Hotswap aus +- [ ] Bilddaten werden größen- und URL-validiert - [ ] 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 - [ ] Qwen-Profil wird nach FLUX wiederhergestellt - [ ] Whisper transkribiert deutsche und englische Testdatei diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 042b3e9..88727b0 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -4,7 +4,7 @@ - Qwen3.8-27B IQ4-MIX und das getestete MTP2-Profil - 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 - Router, Whisper, XTTS und Websuche - spezialisierte MCPs nach Sicherheitsprofil @@ -33,7 +33,7 @@ 8. Medium und Long einzeln testen. 9. Router installieren und Profilwechsel testen. 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. 13. Erst danach Clients umstellen. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index f5335c0..8145dc2 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -4,7 +4,7 @@ | 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 | | Long | `qwen-long` | 131.072 | lange Hermes-/MCP-Sitzungen | diff --git a/docs/RECOVERY_REQUIREMENTS.md b/docs/RECOVERY_REQUIREMENTS.md index 1a741ba..3513597 100644 --- a/docs/RECOVERY_REQUIREMENTS.md +++ b/docs/RECOVERY_REQUIREMENTS.md @@ -31,7 +31,6 @@ Pflichtrollen: - Qwen Fast/Long IQ4-MIX - Qwen Medium IQ4_XS Pure -- Qwen Q3 Vision - BF16 Vision-Projektor - Whisper large-v3-turbo - FLUX.2 klein diff --git a/platform/llama/README.md b/platform/llama/README.md index 23f710e..d262658 100644 --- a/platform/llama/README.md +++ b/platform/llama/README.md @@ -16,7 +16,7 @@ nach Standardbenchmark, Tool-Calling-Test und Kontexttest übernommen. - Fast und Long: Qwen3.8-27B IQ4-MIX mit MTP2 - 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 werden im lokalen Modellmanifest verwaltet. diff --git a/platform/models/manifest.example.yaml b/platform/models/manifest.example.yaml index b79a720..5650e75 100644 --- a/platform/models/manifest.example.yaml +++ b/platform/models/manifest.example.yaml @@ -12,14 +12,8 @@ models: 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 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: - role: vision-projector + role: integrated-vision-projector-for-all-qwen-profiles source: "REPLACE_WITH_MODEL_REPOSITORY" file: mmproj-BF16.gguf target: /opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf diff --git a/platform/profiles/profile-long.conf b/platform/profiles/profile-long.conf index 4a7867f..20b3ee2 100644 --- a/platform/profiles/profile-long.conf +++ b/platform/profiles/profile-long.conf @@ -1,6 +1,6 @@ [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] 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 diff --git a/platform/profiles/profile-medium.conf b/platform/profiles/profile-medium.conf index 491685b..b43d02b 100644 --- a/platform/profiles/profile-medium.conf +++ b/platform/profiles/profile-medium.conf @@ -1,6 +1,6 @@ [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] 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 diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index acc40b0..3fdf4fb 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -7,7 +7,7 @@ Profilen um: Profil Kontext ------ -------- - fast 73728 + fast 76800 medium 94208 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 Fehlgeschlagener Generierung wiederhergestellt). -Vision-Orchestrierung (Q3 "Augen", temporär): - POST /v1/chat/completions mit Bild im letzten - User-Message → ein temporäres Q3-Vision-Modell - (llama-server + mmproj) wird geladen, analysiert - das Bild und wird wieder entladen; das Hauptprofil - wird immer wiederhergestellt (try/finally) und - erzeugt die Endantwort. Die Vision-Analyse bleibt - intern (kein sichtbarer Assistant-Turn). +Vision: + POST /v1/chat/completions mit Bild wird direkt an + das aktive multimodale Qwen-Profil weitergeleitet. + Der Vision-Projektor ist Bestandteil des Profils; + es findet kein Modellwechsel statt. Nur Python-Standardbibliothek. Logging nach stdout (journald). """ @@ -56,7 +53,6 @@ from __future__ import annotations import base64 import binascii import email -import hashlib import ipaddress import json import logging @@ -73,7 +69,6 @@ import http.client import urllib.error import urllib.parse import urllib.request -from collections import OrderedDict from email.parser import BytesParser from email.policy import compat32 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_DAYS = int(os.environ.get("IMAGE_RETENTION_DAYS", "30")) -# --- Vision-Orchestrierung (Q3 "Augen", temporär) --- -# Chat-Requests mit Bild im letzten User-Message lösen einen -# temporären Hotswap aus: Hauptprofil raus → Q3+mmproj rein → -# Bild analysieren → Q3 raus → Hauptprofil rein (try/finally). -LLAMA_SERVER_BIN = os.environ.get( - "LLAMA_SERVER_BIN", "/opt/mike-ai/llama.cpp/build/bin/llama-server") -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") +# --- Multimodale Chat-Eingaben --- +# Bilder werden validiert und direkt an das aktive Qwen-Profil weitergeleitet. +CHAT_IMAGE_MAX_BYTES = int(os.environ.get( + "CHAT_IMAGE_MAX_BYTES", str(20 * 1024 * 1024))) +CHAT_IMAGE_ALLOW_REMOTE_URLS = os.environ.get( + "CHAT_IMAGE_ALLOW_REMOTE_URLS", "false").lower() in {"1", "true", "yes"} # Erlaubte Auflösungen (Breite x Höhe). FLUX.2 klein ist für 1 MP # 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: """Gemeinsamer, thread-sicherer Zustand. - lock : zentraler GPU-/Model-Lock. Wird von Profilwechsel, - Image-Generation UND Vision-Swap gehalten → - gegenseitiger Ausschluss, kein Race zwischen beiden. + lock : zentraler GPU-/Model-Lock. Wird von Profilwechsel und + Bildgenerierung gehalten. avail_lock : schützt qwen_unavailable + active_chats (Chat-Waiting). """ 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. self.lock = threading.RLock() self.switching: str | None = None self.started = time.time() self.image = _ImageState() - self.vision = _VisionState() self.qwen_unavailable = True self.active_chats = 0 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 = ( - "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 = { +_CHAT_IMAGE_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. +def _normalize_chat_image(image_url: str) -> str: + """Validiert ein Bild und liefert eine begrenzte data-URL. - 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). + Remote-Downloads sind standardmäßig deaktiviert. Wenn sie ausdrücklich + aktiviert werden, lädt der Router das Bild nach SSRF-Prüfung selbst und + übergibt llama.cpp ausschließlich eine data-URL. """ 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: + 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") 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: + if not raw or len(raw) > CHAT_IMAGE_MAX_BYTES: 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 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( + "Bild muss eine data-URL oder eine gültige HTTP(S)-URL sein") + if not CHAT_IMAGE_ALLOW_REMOTE_URLS: raise ValueError( "Remote-Bild-URLs sind deaktiviert; Bild bitte als data-URL hochladen") try: @@ -1100,7 +898,8 @@ def _normalize_vision_image(image_url: str) -> str: 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 + 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]) @@ -1115,49 +914,50 @@ def _normalize_vision_image(image_url: str) -> str: 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") + 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) + if content_type not in _CHAT_IMAGE_DATA_TYPES: + raise ValueError( + "Remote-Inhalt ist kein unterstütztes Bild " + f"({content_type})") + raw = response.read(CHAT_IMAGE_MAX_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)") + 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," + base64.b64encode(raw).decode("ascii")) -def _image_hash(image_url: str) -> str: - """Stabiler Hash für ein Bild (Base64-Payload oder URL). - - Dasselbe Bild → derselbe Hash (unabhängig von Chat/Turn). Dient als - Schlüssel für den Vision-Analyse-Cache. - """ - if image_url.startswith("data:"): - payload = image_url.split(",", 1)[1] if "," in image_url else "" - try: - raw = base64.b64decode(payload) - return "img:" + hashlib.sha256(raw).hexdigest()[:32] - except (ValueError, TypeError): - return "b64:" + hashlib.sha256(payload.encode()).hexdigest()[:32] - return "url:" + hashlib.sha256(image_url.encode()).hexdigest()[:32] - - -def _vision_cached_analysis(img_hash: str) -> str | None: - """Liefert die gecachte Vision-Analyse für einen Bild-Hash (oder None).""" - with STATE.vision.cache_lock: - return STATE.vision.analysis_cache.get(img_hash) - - -def _vision_store_analysis(img_hash: str, analysis: str) -> None: - """Speichert eine Vision-Analyse im Cache (LRU-begrenzt).""" - with STATE.vision.cache_lock: - STATE.vision.analysis_cache[img_hash] = analysis - 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 _normalize_chat_images(data: dict) -> dict: + """Validiert alle image_url-Parts, ohne sie aus dem Chat zu entfernen.""" + out = json.loads(json.dumps(data)) + messages = out.get("messages") + if not isinstance(messages, list): + return out + for message in messages: + if not isinstance(message, dict): + continue + content = message.get("content") + if not isinstance(content, list): + continue + for part in content: + if not isinstance(part, dict) or part.get("type") != "image_url": + continue + image = part.get("image_url") + if isinstance(image, dict): + url = image.get("url") + if not isinstance(url, str) or not url: + raise ValueError("image_url.url fehlt") + image["url"] = _normalize_chat_image(url) + elif isinstance(image, str) and image: + part["image_url"] = _normalize_chat_image(image) + else: + raise ValueError("image_url fehlt") + return out def _request_has_image(data: dict) -> bool: @@ -1165,260 +965,12 @@ def _request_has_image(data: dict) -> bool: messages = data.get("messages") if not isinstance(messages, list): return False - for msg in messages: - if not isinstance(msg, dict): - continue - content = msg.get("content") - if isinstance(content, list): - 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 + return any( + isinstance(part, dict) and part.get("type") == "image_url" + for message in messages if isinstance(message, dict) + for part in (message.get("content") + if isinstance(message.get("content"), list) else []) + ) # --------------------------------------------------------------------------- @@ -1494,8 +1046,6 @@ class Handler(BaseHTTPRequestHandler): self._speech() elif path == "/v1/audio/transcriptions" and self.command == "POST": self._transcribe() - elif path == "/vision/test" and self.command == "POST": - self._vision_test() elif path == "/images" and self.command == "GET": self._images_list() elif path.startswith("/images/") and self.command == "GET": @@ -1694,13 +1244,6 @@ class Handler(BaseHTTPRequestHandler): "last_seconds": img.last_seconds, "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(), "stt": stt_status(), } @@ -2128,63 +1671,6 @@ class Handler(BaseHTTPRequestHandler): "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 ---------- def _forward(self) -> None: @@ -2197,8 +1683,8 @@ class Handler(BaseHTTPRequestHandler): return # /v1/streams/lookup (Open-WebUI-Stream-Recovery): darf NIEMALS auf - # die Qwen-Wiederherstellung warten (während Vision-Hotswap, - # Profilwechsel oder Image-Job ist Qwen down). Wenn Qwen down ist, + # die Qwen-Wiederherstellung warten (während Profilwechsel oder + # Image-Job ist Qwen down). Wenn Qwen down ist, # gibt es per Definition keine aktiven Streams → sofortige lokale # Antwort []. Ansonsten normal an llama.cpp weiterleiten. if path == "/v1/streams/lookup": @@ -2276,24 +1762,17 @@ class Handler(BaseHTTPRequestHandler): def _chat_proxy(self, body: bytes | None, data: dict, profile: str | None) -> None: - """Vision-Vorbereitung, Profilwahl und Chat-Lease als eine Transaktion.""" + """Bildvalidierung, 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") + data = _normalize_chat_images(data) + log.info("Vision: Bild wird direkt an das aktive " + "multimodale Qwen-Profil weitergeleitet") up = upstream_status() 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.""" 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)]) + if worker == "image": + markers = [IMAGE_WORKER] terminated = terminate_recorded_worker( previous, markers, lambda msg: log.warning("Recovery: %s", msg)) if terminated: diff --git a/router/router_profiles.json b/router/router_profiles.json index 2521651..2c6a34b 100644 --- a/router/router_profiles.json +++ b/router/router_profiles.json @@ -1,8 +1,8 @@ { "profiles": { "fast": { - "context": 73728, - "model_alias": "qwen38-27b-iq4mix-72k-mtp2" + "context": 76800, + "model_alias": "qwen38-27b-iq4mix-76k-mtp2-vision" }, "medium": { "context": 94208, diff --git a/router/router_support.py b/router/router_support.py index 23ec283..7ed70cd 100644 --- a/router/router_support.py +++ b/router/router_support.py @@ -33,7 +33,7 @@ def load_profile_registry(path: str | None) -> dict[str, dict]: """ fallback = { - "fast": {"context": 73728, "model_alias": None}, + "fast": {"context": 76800, "model_alias": None}, "medium": {"context": 94208, "model_alias": None}, "long": {"context": 131072, "model_alias": None}, }