Remove ineffective photo restoration pipeline

This commit is contained in:
Mikei386
2026-09-08 09:40:43 +02:00
parent f82dc081c9
commit e82e0340e4
9 changed files with 17 additions and 552 deletions
-43
View File
@@ -618,11 +618,6 @@ services:
IMAGE_WORKER_URL: http://image-worker:8086
IMAGE_WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
IMAGE_MODEL_NAME: FLUX.2-klein-9B-fp8-beta
RESTORATION_WORKER_URL: http://restoration-worker:8087
RESTORATION_WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
RESTORATION_MODEL_NAME: HYPIR-SD2
RESTORATION_CHAT_MODEL: restauration
RESTORATION_CHAT_PROFILE: fast
CHAT_IMAGE_ALLOW_REMOTE_URLS: "false"
ENABLE_IMAGE_GENERATION: "true"
ENABLE_TTS: "true"
@@ -699,44 +694,6 @@ services:
timeout: 3s
retries: 12
restoration-worker:
build:
context: platform/docker/restoration-worker
args:
HYPIR_COMMIT: b61d107c6cef38f01a93c7833558869731cfa8c1
image: mike-ai/restoration-worker:hypir-sd2
container_name: mike-ai-restoration-worker
restart: "no"
profiles: [image]
labels:
com.mike-ai.image-worker: restore
gpus: all
read_only: true
tmpfs: ["/tmp:size=1g,mode=1777"]
volumes:
- "${HYPIR_MODEL_DIR:-/data/models/HYPIR}:/models/hypir:ro"
- "${HYPIR_SD2_DIR:-/data/models/stable-diffusion-2-1-base}:/models/sd2:ro"
- router-images:/data/images
environment:
NVIDIA_VISIBLE_DEVICES: "${RESTORATION_GPU_DEVICE:-1}"
NVIDIA_DRIVER_CAPABILITIES: compute,utility
WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
HYPIR_BASE_MODEL: /models/sd2
HYPIR_WEIGHT_FILE: /models/hypir/HYPIR_sd2.pth
HYPIR_DEVICE: cuda:0
# Empty conditioning avoids prompt-driven repainting in the conservative
# restoration profile. Set to passthrough only for deliberate experiments.
HYPIR_PROMPT_MODE: "empty"
IMAGE_DIR: /data/images
networks: [inference]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8087/health', timeout=2)"]
interval: 5s
timeout: 3s
retries: 12
piper:
build:
context: platform/docker/piper
-3
View File
@@ -23,9 +23,6 @@ IMAGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
HF_TOKEN_FILE=/root/.cache/huggingface/token
FLUX_COMPONENT_DIR=/data/models/FLUX.2-klein-9B-components
FLUX_TRANSFORMER_DIR=/data/models/FLUX.2-klein-9B-fp8
HYPIR_MODEL_DIR=/data/models/HYPIR
HYPIR_SD2_DIR=/data/models/stable-diffusion-2-1-base
RESTORATION_GPU_DEVICE=1
# Headless remote reachability. Firmware power-loss recovery is configured
# separately once at the physical machine.
-40
View File
@@ -24,13 +24,10 @@ from router_support import ( # noqa: E402
load_profile_registry,
)
from ai_profile_router import ( # noqa: E402
RESTORATION_CHAT_MODEL,
VIRTUAL_MODELS,
STATE,
_cap_chat_generation,
_context_matches,
_inject_global_system_policy,
_inject_restoration_system_policy,
_normalize_chat_image,
_normalize_chat_images,
_normalize_llamacpp_reasoning,
@@ -83,10 +80,6 @@ class RuntimeStoreTests(unittest.TestCase):
class ProfileRegistryTests(unittest.TestCase):
def test_restoration_model_maps_to_fast_instruction_profile(self) -> None:
self.assertEqual(RESTORATION_CHAT_MODEL, "restauration")
self.assertEqual(VIRTUAL_MODELS[RESTORATION_CHAT_MODEL], "fast")
def test_fallback_contains_uncensored_profile(self) -> None:
registry = load_profile_registry(None)
self.assertEqual(registry["uncensored"]["context"], 80000)
@@ -277,39 +270,6 @@ class GlobalSystemPolicyTests(unittest.TestCase):
self._inject(request, "/v1/images/generations"), request)
class RestorationSystemPolicyTests(unittest.TestCase):
def test_chat_policy_requires_image_tool_and_preservation(self) -> None:
request = {"messages": [{"role": "user", "content": "Mach schöner"}]}
normalized = _inject_restoration_system_policy(
request, "/v1/chat/completions")
policy = normalized["messages"][0]["content"]
self.assertIn("image generation/editing tool", policy)
self.assertIn("Preserve identity", policy)
self.assertIn("Do not attempt restoration with Python", policy)
def test_responses_policy_keeps_client_instructions(self) -> None:
request = {"instructions": "Client policy.", "input": "Mach schöner"}
normalized = _inject_restoration_system_policy(
request, "/v1/responses")
self.assertIn("Photo-restoration mode", normalized["instructions"])
self.assertTrue(normalized["instructions"].endswith("Client policy."))
def test_chat_policy_merges_with_existing_leading_system_message(self) -> None:
request = {"messages": [
{"role": "system", "content": "Global policy."},
{"role": "user", "content": "Mach schöner"},
]}
normalized = _inject_restoration_system_policy(
request, "/v1/chat/completions")
system_messages = [
message for message in normalized["messages"]
if message.get("role") == "system"
]
self.assertEqual(len(system_messages), 1)
self.assertIn("Photo-restoration mode", system_messages[0]["content"])
self.assertTrue(system_messages[0]["content"].endswith("Global policy."))
class RetentionTests(unittest.TestCase):
def test_oldest_pairs_are_removed(self) -> None:
with tempfile.TemporaryDirectory() as temp:
-77
View File
@@ -1,77 +0,0 @@
# GPU-Bildrestaurierung auf Athena
Stand: 7. September 2026
## Zweck
Athena stellt zusätzlich zur kreativen FLUX-Bildgenerierung eine konservative
Fotorestaurierung mit `HYPIR-SD2` bereit. HYPIR wird ausschließlich für
Referenzbilder verwendet und soll Rauschen, Unschärfe und Kompressionsschäden
reduzieren, ohne die Szene wie ein Text-zu-Bild-Modell vollständig neu zu
erfinden.
Das Produktionsprofil verwendet absichtlich eine leere HYPIR-Textkonditionierung
(`HYPIR_PROMPT_MODE=empty`). Ausführliche Beschreibungen des Bildinhalts können
bei dem Stable-Diffusion-basierten Modell sonst neue Texturen und Details
erzwingen. Hermes versteht weiterhin die Benutzeranweisung; der Restaurations-
Worker erhält für die eigentliche Rekonstruktion jedoch keinen Kreativprompt.
## Aufruf und Routing
Der OpenAI-kompatible Endpunkt bleibt `/v1/images/edits`. Das gewünschte
Backend wird über `model` gewählt:
```json
{
"model": "HYPIR-SD2",
"prompt": "Restauriere dieses Foto möglichst originalgetreu.",
"image_b64": "...",
"upscale": 1,
"patch_size": 512,
"stride": 256
}
```
Hermes zeigt dafür das zusätzliche Chatmodell `restauration` neben
`qwen-fast`, `qwen-medium`, `qwen-large` und `qwen-ultra`. Es verwendet
`qwen-fast` als kurzes Anweisungsmodell und bindet das Bildwerkzeug fest an
HYPIR. Die Formulierung des Prompts entscheidet nicht über das Backend.
In jedem normalen Chatmodell bleiben Referenzbildänderungen bei FLUX.
## Lebenszyklus
1. Der Router merkt sich das aktive Qwen-Profil und wartet laufende Chats ab.
2. Profile Controller stoppt Qwen und Qwen3-TTS.
3. Der disposable `restoration-worker` lädt HYPIR auf der RTX 5080.
4. Nach der Ausgabe wird der Worker beendet und sein CUDA-Kontext freigegeben.
5. Qwen3-TTS und das zuvor aktive Qwen-Profil werden wiederhergestellt.
FLUX- und HYPIR-Worker können durch dieselbe Docker-Label-Allowlist nie
gleichzeitig mit einem Qwen-Profil laufen. Das Originalbild wird nicht
überschrieben; Eingaben werden nur als temporäre Dateien im privaten
`router-images`-Volume abgelegt und nach dem Auftrag entfernt.
## Modelle und Lizenz
- Code: `XPixelGroup/HYPIR`, Commit
`b61d107c6cef38f01a93c7833558869731cfa8c1`
- Restaurationsgewicht: `lxq007/HYPIR/HYPIR_sd2.pth`
- Basis: `LanguageMachines/stable-diffusion-2-1-base`, nur die benötigten
Diffusers-Komponenten
- HYPIR ist ausschließlich für nichtkommerzielle Nutzung freigegeben.
## Test
Für die Produktionsprobe in Hermes zuerst `restauration` auswählen, dann ein
Foto hochladen und beispielsweise `Mach das bitte schöner und schärfer`
schreiben. Danach sind zu prüfen:
```bash
docker ps -a --filter name=mike-ai-restoration-worker \
--format '{{.Names}} {{.Status}}'
nvidia-smi
curl -fsS http://127.0.0.1:8081/status
```
Erwartet: Restaurations-Worker beendet, vorheriges Qwen-Profil und TTS gesund,
Routerphase `idle`.
+2 -6
View File
@@ -4,11 +4,8 @@ Hermes backend plugin for the OpenAI-compatible image API exposed by the
Athena profile router. The router starts the local FLUX worker on demand,
unloads the active LLM and Qwen3-TTS, and restores both after generation.
When the explicit Hermes model `restauration` is selected, reference-image
requests are routed to Athena's `HYPIR-SD2` worker. Creative edits in every
normal chat model continue to use FLUX. Prompt text is deliberately never used
for routing, so words such as "improve" cannot accidentally switch pipelines.
No second Hermes provider or desktop installation is required.
Reference-image requests use FLUX for creative edits. No second Hermes
provider or desktop installation is required.
## Gateway installation
@@ -46,7 +43,6 @@ second computer connected to the same gateway needs no plugin installation.
Optional overrides:
- `ATHENA_IMAGE_MODEL` defaults to `FLUX.2-klein-9B-fp8-beta`.
- `ATHENA_RESTORATION_MODEL` defaults to `HYPIR-SD2`.
- `ROUTER_API_KEY` is accepted as a migration fallback.
- An existing `HERMES_CUSTOM_192_168_1_212_8081_API_KEY` is accepted as the
final fallback, so an existing Athena chat-provider setup needs no duplicate
+3 -44
View File
@@ -34,7 +34,6 @@ _SIZES = {
}
_DEFAULT_BASE_URL = "http://192.168.1.212:8081/v1"
_DEFAULT_MODEL = "FLUX.2-klein-9B-fp8-beta"
_DEFAULT_RESTORATION_MODEL = "HYPIR-SD2"
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
@@ -48,34 +47,6 @@ def _model() -> str:
return os.environ.get("ATHENA_IMAGE_MODEL", "").strip() or _DEFAULT_MODEL
def _restoration_model() -> str:
return (os.environ.get("ATHENA_RESTORATION_MODEL", "").strip()
or _DEFAULT_RESTORATION_MODEL)
def _restoration_profile_selected() -> bool:
"""Use HYPIR only when Hermes explicitly selected the restoration model."""
try:
from hermes_cli.config import load_config
config = load_config()
model = config.get("model") if isinstance(config, dict) else None
selected = model.get("default") if isinstance(model, dict) else None
return isinstance(selected, str) and selected.casefold() in {
"restauration", "restoration", "qwen-restoration",
}
except Exception:
return False
def _select_model(requested: object) -> str:
"""Resolve an explicit image/profile selection; never inspect prompt text."""
if _restoration_profile_selected():
return _restoration_model()
if isinstance(requested, str) and requested.strip() == _restoration_model():
return _restoration_model()
return _model()
def _api_key() -> str:
"""Prefer a scoped key; accept the existing router key for migration."""
return (
@@ -145,12 +116,6 @@ class AthenaLocalImageProvider(ImageGenProvider):
"speed": "local",
"strengths": "Private local generation and multi-reference editing",
"price": "local / no cloud",
}, {
"id": _restoration_model(),
"display": "HYPIR-SD2 Restoration on Athena",
"speed": "local",
"strengths": "Faithful denoise, deblur and photo restoration",
"price": "local / no cloud",
}]
def default_model(self) -> Optional[str]:
@@ -213,10 +178,9 @@ class AthenaLocalImageProvider(ImageGenProvider):
sources.append(image_url.strip())
sources.extend(normalize_reference_images(reference_image_urls) or [])
# Hermes may expose the primary upload through both ``image_url`` and
# ``reference_image_urls``. Preserve order while removing duplicates;
# restoration deliberately accepts exactly one physical source image.
# ``reference_image_urls``. Preserve order while removing duplicates.
sources = list(dict.fromkeys(sources))[:4]
model = _select_model(kwargs.get("model"))
model = _model()
try:
encoded_sources = [
base64.b64encode(_load_private_image(source)).decode("ascii")
@@ -243,9 +207,6 @@ class AthenaLocalImageProvider(ImageGenProvider):
endpoint = "edits"
request_data["image_b64"] = encoded_sources[0]
request_data["reference_images_b64"] = encoded_sources[1:]
if model == _restoration_model():
request_data.update({"upscale": 1, "patch_size": 512,
"stride": 256})
request = urllib.request.Request(
f"{base_url}/images/{endpoint}",
data=json.dumps(request_data).encode("utf-8"), method="POST",
@@ -283,9 +244,7 @@ class AthenaLocalImageProvider(ImageGenProvider):
prompt=clean_prompt, aspect_ratio=aspect)
try:
prefix = ("athena_hypir" if model == _restoration_model()
else "athena_flux2")
saved = save_b64_image(b64_data, prefix=prefix)
saved = save_b64_image(b64_data, prefix="athena_flux2")
except Exception as exc:
return error_response(
error=f"Generated image could not be saved: {exc}",
@@ -1,20 +0,0 @@
FROM pytorch/pytorch:2.11.0-cuda12.8-cudnn9-runtime
ARG HYPIR_COMMIT=b61d107c6cef38f01a93c7833558869731cfa8c1
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates python3.12-venv && \
git clone https://github.com/XPixelGroup/HYPIR.git /opt/HYPIR && \
cd /opt/HYPIR && git checkout "${HYPIR_COMMIT}" && \
rm -rf /opt/HYPIR/.git /var/lib/apt/lists/* && \
python -m venv --system-site-packages /opt/restore-venv && \
/opt/restore-venv/bin/pip install --no-cache-dir \
accelerate==1.4.0 diffusers==0.32.2 peft==0.14.0 \
transformers==4.49.0 einops==0.8.1 omegaconf==2.3.0 \
opencv-python-headless==4.11.0.86 safetensors pillow && \
useradd --system --uid 10002 --home /nonexistent --shell /usr/sbin/nologin restoration-worker
COPY restoration_worker.py /app/restoration_worker.py
USER 10002:10002
WORKDIR /opt/HYPIR
ENV HF_HOME=/tmp/huggingface
ENTRYPOINT ["/opt/restore-venv/bin/python", "/app/restoration_worker.py"]
@@ -1,199 +0,0 @@
#!/usr/bin/env python3
"""Private HYPIR-SD2 still-image restoration worker for Athena.
The container is intentionally disposable. The profile controller starts it
only for a restoration request and stops it before restoring the previous LLM
profile, which guarantees that CUDA allocations cannot leak into text mode.
"""
from __future__ import annotations
import gc
import json
import os
import signal
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
HOST = os.environ.get("WORKER_HOST", "0.0.0.0")
PORT = int(os.environ.get("WORKER_PORT", "8087"))
TOKEN = os.environ.get("WORKER_TOKEN", "").strip()
BASE_MODEL = os.environ.get("HYPIR_BASE_MODEL", "/models/sd2")
WEIGHT_FILE = os.environ.get("HYPIR_WEIGHT_FILE", "/models/hypir/HYPIR_sd2.pth")
OUTPUT_DIR = Path(os.environ.get("IMAGE_DIR", "/data/images")).resolve()
DEVICE = os.environ.get("HYPIR_DEVICE", "cuda:0")
PROMPT_MODE = os.environ.get("HYPIR_PROMPT_MODE", "empty").strip().lower()
ACTIVE = False
MODEL = None
LOAD_SECONDS = 0.0
# The entrypoint lives in /app, so Python would otherwise omit the cloned
# upstream repository from sys.path even though WORKDIR is /opt/HYPIR.
sys.path.insert(0, "/opt/HYPIR")
LORA_MODULES = [
"to_k", "to_q", "to_v", "to_out.0", "conv", "conv1", "conv2",
"conv_shortcut", "conv_out", "proj_in", "proj_out", "ff.net.2",
"ff.net.0.proj",
]
if len(TOKEN) < 32:
raise RuntimeError("WORKER_TOKEN is missing or too short")
signal.signal(signal.SIGTERM, lambda *_: os._exit(0))
def _load_model():
global MODEL, LOAD_SECONDS
if MODEL is not None:
return MODEL
from HYPIR.enhancer.sd2 import SD2Enhancer
started = time.monotonic()
MODEL = SD2Enhancer(
base_model_path=BASE_MODEL,
weight_path=WEIGHT_FILE,
lora_modules=LORA_MODULES,
lora_rank=256,
model_t=200,
coeff_t=200,
device=DEVICE,
)
MODEL.init_models()
LOAD_SECONDS = time.monotonic() - started
return MODEL
def _safe_source(name: object) -> Path:
if not isinstance(name, str) or Path(name).name != name:
raise ValueError("invalid source image filename")
source = (OUTPUT_DIR / name).resolve()
if source.parent != OUTPUT_DIR or not source.is_file():
raise ValueError("source image not found")
return source
def _prompt(value: object) -> str:
# HYPIR inherits text-guided texture synthesis from Stable Diffusion. For
# the explicit restoration profile fidelity is more important than
# creativity, so the production default disables text conditioning. A
# future experimental profile can opt back into it without changing code.
if PROMPT_MODE == "empty":
return ""
text = value.strip() if isinstance(value, str) else ""
operation_words = {
"restauriere", "restaurieren", "restore", "verbessere", "verbessern",
"enhance", "repariere", "reparieren", "dieses", "das", "bild", "foto",
"photo", "image", "bitte", "schärfer", "schaerfer", "aufarbeiten",
}
words = {part.strip(".,:;!?-_").lower() for part in text.split()}
if not text or words.issubset(operation_words):
return "high quality natural photograph, faithful details, realistic textures"
return text[:1000]
def restore(data: dict) -> dict:
global ACTIVE
import torch
import numpy as np
from PIL import Image
filename = data.get("filename")
if (not isinstance(filename, str) or Path(filename).name != filename
or not filename.endswith(".png")):
raise ValueError("invalid filename")
source_files = data.get("source_files") or []
if not isinstance(source_files, list) or len(source_files) != 1:
raise ValueError("HYPIR restoration requires exactly one source image")
source = _safe_source(source_files[0])
upscale = int(data.get("upscale", 1))
if upscale not in (1, 2, 4):
raise ValueError("upscale must be 1, 2, or 4")
patch_size = int(data.get("patch_size", 512))
stride = int(data.get("stride", 256))
if patch_size not in (512, 768, 1024) or stride <= 0 or stride > patch_size:
raise ValueError("invalid patch_size/stride")
ACTIVE = True
started = time.monotonic()
try:
model = _load_model()
with Image.open(source) as opened:
image = opened.convert("RGB")
array = np.asarray(image, dtype=np.float32) / 255.0
tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0)
result = model.enhance(
lq=tensor,
prompt=_prompt(data.get("prompt")),
scale_by="factor",
upscale=upscale,
patch_size=patch_size,
stride=stride,
return_type="pil",
)[0]
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
result.save(OUTPUT_DIR / filename)
return {
"status": "ok",
"filename": filename,
"seconds": round(time.monotonic() - started, 3),
"load_seconds": round(LOAD_SECONDS, 3),
"model": "HYPIR-SD2",
"upscale": upscale,
}
finally:
gc.collect()
torch.cuda.empty_cache()
ACTIVE = False
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args: object) -> None:
print(f"[hypir-restore] {self.client_address[0]} {fmt % args}", flush=True)
def reply(self, status: int, payload: dict) -> None:
body = json.dumps(payload, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
if self.path == "/health":
self.reply(200, {"status": "ok", "model_loaded": MODEL is not None,
"active": ACTIVE, "model": "HYPIR-SD2"})
else:
self.reply(404, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802
if self.headers.get("Authorization", "") != f"Bearer {TOKEN}":
self.reply(401, {"error": "unauthorized"})
return
if self.path != "/restore":
self.reply(404, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length < 2 or length > 16384:
raise ValueError("invalid request size")
self.reply(200, restore(json.loads(self.rfile.read(length))))
except Exception as exc:
print(f"[hypir-restore] restoration failed: "
f"{type(exc).__name__}: {str(exc)[:1000]}", flush=True)
self.reply(400, {"status": "error", "message": str(exc)})
def main() -> None:
global MODEL
try:
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
finally:
MODEL = None
gc.collect()
if __name__ == "__main__":
main()
+12 -120
View File
@@ -138,16 +138,6 @@ IMAGE_WORKER_URL = os.environ.get("IMAGE_WORKER_URL", "").rstrip("/")
IMAGE_WORKER_TOKEN = os.environ.get("IMAGE_WORKER_TOKEN", "").strip()
IMAGE_MODEL_NAME = os.environ.get(
"IMAGE_MODEL_NAME", "FLUX.2-klein-9B-fp8-beta")
RESTORATION_WORKER_URL = os.environ.get(
"RESTORATION_WORKER_URL", "").rstrip("/")
RESTORATION_WORKER_TOKEN = os.environ.get(
"RESTORATION_WORKER_TOKEN", IMAGE_WORKER_TOKEN).strip()
RESTORATION_MODEL_NAME = os.environ.get(
"RESTORATION_MODEL_NAME", "HYPIR-SD2")
RESTORATION_CHAT_MODEL = os.environ.get(
"RESTORATION_CHAT_MODEL", "restauration").strip()
RESTORATION_CHAT_PROFILE = os.environ.get(
"RESTORATION_CHAT_PROFILE", "fast").strip()
IMAGE_DIR = os.environ.get(
"IMAGE_DIR", "/opt/mike-ai/ai-profile-router/images")
IMAGE_WORKER_LOG = os.environ.get(
@@ -225,12 +215,6 @@ VIRTUAL_MODELS = {
(EXPECTED_MODELS.get(name) or f"qwen-{name}"): name
for name in PROFILES
}
if RESTORATION_CHAT_PROFILE not in PROFILES:
raise ConfigurationError(
f"RESTORATION_CHAT_PROFILE ist unbekannt: {RESTORATION_CHAT_PROFILE!r}")
if not RESTORATION_CHAT_MODEL or RESTORATION_CHAT_MODEL in VIRTUAL_MODELS:
raise ConfigurationError("RESTORATION_CHAT_MODEL fehlt oder kollidiert")
VIRTUAL_MODELS[RESTORATION_CHAT_MODEL] = RESTORATION_CHAT_PROFILE
log = logging.getLogger("ai-profile-router")
AUTH: AuthPolicy | None = None
@@ -874,23 +858,16 @@ class _Worker:
RUNTIME.clear_worker("image")
def _worker(model: str = IMAGE_MODEL_NAME) -> _Worker:
def _worker() -> _Worker:
"""Worker-Instanz liefern (startet bei Bedarf)."""
img = STATE.image
if not img.worker or not img.worker.alive():
if img.worker:
img.worker.stop()
if model == RESTORATION_MODEL_NAME:
if not RESTORATION_WORKER_URL:
raise RuntimeError("Restaurations-Worker ist nicht konfiguriert")
img.worker = _RemoteWorker(
kind="restore", url=RESTORATION_WORKER_URL,
token=RESTORATION_WORKER_TOKEN, endpoint="/restore")
else:
img.worker = (_RemoteWorker(kind="image", url=IMAGE_WORKER_URL,
token=IMAGE_WORKER_TOKEN,
endpoint="/generate")
if IMAGE_WORKER_URL else _Worker())
img.worker = (_RemoteWorker(kind="image", url=IMAGE_WORKER_URL,
token=IMAGE_WORKER_TOKEN,
endpoint="/generate")
if IMAGE_WORKER_URL else _Worker())
img.worker.start()
return img.worker
@@ -1046,7 +1023,6 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
quality: str = "standard",
source_files: list[str] | None = None,
model: str = IMAGE_MODEL_NAME,
restore_options: dict | None = None,
) -> tuple[list[str], str | None]:
"""Orchestriert die Bildgenerierung inkl. Qwen-Hotswap.
@@ -1091,7 +1067,7 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
# 2) Worker starten (Modell wird beim ersten generate geladen).
img.phase = "loading-image"
worker = _worker(model)
worker = _worker()
# 3) Generieren.
for i in range(n):
@@ -1110,7 +1086,6 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
"output": output,
"source_files": source_files or [],
}
worker_payload.update(restore_options or {})
resp = worker.request(worker_payload, timeout=IMAGE_GEN_TIMEOUT)
if resp.get("status") != "ok":
raise RuntimeError(
@@ -1129,8 +1104,7 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
"steps": steps,
"guidance": guidance,
"quality": quality,
"mode": ("image-restoration" if model == RESTORATION_MODEL_NAME
else "image-edit" if source_files else "text-to-image"),
"mode": ("image-edit" if source_files else "text-to-image"),
"reference_images": len(source_files or []),
"seconds": resp.get("seconds"),
"model": model,
@@ -1383,39 +1357,6 @@ def _inject_global_system_policy(data: dict, path: str) -> dict:
return data
def _inject_restoration_system_policy(data: dict, path: str) -> dict:
"""Make the explicitly selected restoration model use the image tool."""
policy = (
"Photo-restoration mode is selected. When the user supplies an image, "
"use the image generation/editing tool exactly once with that source "
"image and the user's requested restoration. Preserve identity, "
"anatomy, pose, composition and objects unless the user explicitly "
"asks for a creative change. Do not attempt restoration with Python, "
"PIL, OpenCV or shell tools."
)
if path == "/v1/chat/completions":
messages = data.get("messages")
if isinstance(messages, list):
# Qwen's chat template permits exactly one system message and it
# must be the first message. The global router policy may already
# have created that message, so extend it instead of inserting a
# second system message in front of it.
if (messages and isinstance(messages[0], dict)
and messages[0].get("role") == "system"
and isinstance(messages[0].get("content"), str)):
existing = messages[0]["content"]
if policy not in existing:
messages[0]["content"] = f"{policy}\n\n{existing}"
else:
messages.insert(0, {"role": "system", "content": policy})
elif path == "/v1/responses":
instructions = data.get("instructions")
data["instructions"] = (
f"{policy}\n\n{instructions}"
if isinstance(instructions, str) and instructions else policy)
return data
def _normalize_llamacpp_reasoning(data: dict) -> dict:
"""Mappt OpenAI/Hermes-Reasoning auf llama.cpp-Template-Parameter.
@@ -1768,15 +1709,6 @@ class Handler(BaseHTTPRequestHandler):
}
for name, ctx in PROFILES.items()
]
models.append({
"id": RESTORATION_CHAT_MODEL,
"object": "model",
"created": 0,
"owned_by": "ai-profile-router",
"context_length": PROFILES[RESTORATION_CHAT_PROFILE],
"context_window": PROFILES[RESTORATION_CHAT_PROFILE],
"purpose": "image-restoration",
})
if REVIEW_UPSTREAM_URL:
models.append({
"id": REVIEW_MODEL_NAME,
@@ -1932,17 +1864,11 @@ class Handler(BaseHTTPRequestHandler):
return
model = data.get("model", IMAGE_MODEL_NAME)
if model not in {IMAGE_MODEL_NAME, RESTORATION_MODEL_NAME}:
if model != IMAGE_MODEL_NAME:
self._send_error(
400, f"unbekanntes Bildmodell: {model!r}",
"invalid_request_error", "invalid_model")
return
restoring = model == RESTORATION_MODEL_NAME
if restoring and len(source_files) != 1:
self._send_error(
400, f"{RESTORATION_MODEL_NAME} benötigt genau ein Referenzbild",
"invalid_request_error", "missing_image")
return
# Größe
size = data.get("size", "1024x1024")
@@ -1960,11 +1886,6 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(400, f"'n' muss eine Ganzzahl 1..{IMAGE_MAX_N} sein",
"invalid_request_error", "invalid_n")
return
if restoring and n != 1:
self._send_error(400, "Bildrestaurierung unterstützt nur 'n'=1",
"invalid_request_error", "invalid_n")
return
# Qualität / Schritte / Guidance
quality = data.get("quality", IMAGE_DEFAULT_QUALITY)
if quality not in IMAGE_QUALITY:
@@ -1973,9 +1894,8 @@ class Handler(BaseHTTPRequestHandler):
"invalid_request_error", "invalid_quality")
return
steps = data.get("steps", IMAGE_QUALITY[quality])
if (not restoring and
(not isinstance(steps, int) or isinstance(steps, bool)
or steps != 4)):
if (not isinstance(steps, int) or isinstance(steps, bool)
or steps != 4):
self._send_error(400, f"{IMAGE_MODEL_NAME} erfordert 'steps'=4",
"invalid_request_error", "invalid_steps")
return
@@ -1986,35 +1906,11 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(400, "'guidance' muss eine Zahl sein",
"invalid_request_error", "invalid_guidance")
return
if not restoring and guidance != 1.0:
if guidance != 1.0:
self._send_error(400, f"{IMAGE_MODEL_NAME} erfordert 'guidance'=1.0",
"invalid_request_error", "invalid_guidance")
return
restore_options: dict = {}
if restoring:
try:
upscale = int(data.get("upscale", 1))
patch_size = int(data.get("patch_size", 512))
stride = int(data.get("stride", 256))
except (TypeError, ValueError):
self._send_error(400, "ungültige Restaurationsparameter",
"invalid_request_error", "invalid_restore_options")
return
if upscale not in (1, 2, 4):
self._send_error(400, "'upscale' muss 1, 2 oder 4 sein",
"invalid_request_error", "invalid_upscale")
return
if patch_size not in (512, 768, 1024) or not 0 < stride <= patch_size:
self._send_error(400, "ungültige patch_size/stride-Kombination",
"invalid_request_error", "invalid_tiling")
return
restore_options = {
"upscale": upscale,
"patch_size": patch_size,
"stride": stride,
}
seed = data.get("seed")
if seed is not None:
try:
@@ -2039,7 +1935,7 @@ class Handler(BaseHTTPRequestHandler):
try:
results, warning = generate_image(
prompt.strip(), width, height, steps, guidance, seed, n,
quality, source_files, model, restore_options)
quality, source_files, model)
except (ValueError, RuntimeError) as e:
self._send_error(503, str(e), "server_error", "image_generation_failed")
return
@@ -2463,7 +2359,6 @@ class Handler(BaseHTTPRequestHandler):
data = None
requested_profile: str | None = None
requested_review = False
requested_restoration = False
# Virtuelles Modell erkennen. Umschalten und Chat-Lease werden weiter
# unten atomar unter dem zentralen Orchestrierungs-Lock ausgeführt.
if body is not None and self.path.startswith("/v1/"):
@@ -2477,7 +2372,6 @@ class Handler(BaseHTTPRequestHandler):
requested_review = True
elif isinstance(model, str) and model in VIRTUAL_MODELS:
requested_profile = VIRTUAL_MODELS[model]
requested_restoration = model == RESTORATION_CHAT_MODEL
elif isinstance(model, str) and model.startswith("qwen-"):
# qwen-* ist der Namensraum des Routers
self._send_error(400, f"unbekanntes virtuelles Modell: {model}",
@@ -2487,8 +2381,6 @@ class Handler(BaseHTTPRequestHandler):
if path in {"/v1/chat/completions", "/v1/responses"}:
try:
data = _inject_global_system_policy(data, path)
if requested_restoration:
data = _inject_restoration_system_policy(data, path)
except ValueError as exc:
self._send_error(500, str(exc), "server_error",
"system_policy_unavailable")