Replace vision hotswap with native multimodal profiles
This commit is contained in:
@@ -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`):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
+4
-4
@@ -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) -------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
+10
-12
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+85
-607
@@ -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")
|
||||
content_type = response.headers.get_content_type().lower()
|
||||
if content_type not in _VISION_DATA_TYPES:
|
||||
raise ValueError(f"Remote-Inhalt ist kein unterstütztes Bild ({content_type})")
|
||||
raw = response.read(VISION_MAX_IMAGE_BYTES + 1)
|
||||
except urllib.error.URLError as exc:
|
||||
raise ValueError(f"Remote-Bild kann nicht geladen werden: {exc}") from None
|
||||
if not raw or len(raw) > VISION_MAX_IMAGE_BYTES:
|
||||
raise ValueError(
|
||||
f"Bildgröße außerhalb des Limits (max {VISION_MAX_IMAGE_BYTES} Bytes)")
|
||||
"Weiterleitungen zu einem anderen Bild-Host sind nicht erlaubt")
|
||||
content_type = response.headers.get_content_type().lower()
|
||||
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) > 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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user