diff --git a/dev/test_router_support.py b/dev/test_router_support.py index 6029576..c7e8be2 100644 --- a/dev/test_router_support.py +++ b/dev/test_router_support.py @@ -24,6 +24,7 @@ from router_support import ( # noqa: E402 load_profile_registry, ) from ai_profile_router import ( # noqa: E402 + Handler, STATE, _cap_chat_generation, _context_matches, @@ -141,6 +142,45 @@ class ChatImageInputTests(unittest.TestCase): self.assertEqual(request, normalized) +class ImageEditMultipartTests(unittest.TestCase): + def test_openai_image_array_upload_keeps_all_references(self) -> None: + boundary = "OpenClawImageBoundary" + parts = [ + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="model"\r\n\r\n' + "Qwen-Image-2.1-int8\r\n" + ).encode(), + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="prompt"\r\n\r\n' + "Nur die Farbe ändern\r\n" + ).encode(), + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="image[]"; filename="a.png"\r\n' + "Content-Type: image/png\r\n\r\n" + ).encode() + b"PNG-A\r\n", + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="image[]"; filename="b.png"\r\n' + "Content-Type: image/png\r\n\r\n" + ).encode() + b"PNG-B\r\n", + f"--{boundary}--\r\n".encode(), + ] + handler = object.__new__(Handler) + files, fields = handler._parse_multipart_parts( + b"".join(parts), f"multipart/form-data; boundary={boundary}") + + self.assertEqual(fields["model"], "Qwen-Image-2.1-int8") + self.assertEqual(fields["prompt"], "Nur die Farbe ändern") + self.assertEqual( + files, + [("image[]", "a.png", b"PNG-A"), + ("image[]", "b.png", b"PNG-B")], + ) + + class LlamaCppReasoningTests(unittest.TestCase): def test_disabled_values_really_disable_thinking(self) -> None: for effort in (None, "none", "off", "disabled", False): diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index 316e831..c3692d9 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -2223,7 +2223,25 @@ class Handler(BaseHTTPRequestHandler): self._image_request(data, []) def _image_edit(self) -> None: - """Edit with local image bytes supplied by the private Hermes plugin.""" + """Edit with OpenAI multipart uploads or legacy JSON/base64 input.""" + content_type = self.headers.get("Content-Type", "") + if "multipart/form-data" in content_type: + try: + body = self._read_body() + files, data = self._parse_multipart_parts(body, content_type) + except ValueError as exc: + self._send_error(400, str(exc), + "invalid_request_error", "invalid_multipart") + return + images = [payload for name, _filename, payload in files + if name in {"image", "image[]"}] + if not images: + self._send_error(400, "Referenzbild fehlt", + "invalid_request_error", "missing_image") + return + self._image_edit_bytes(data, images) + return + data = self._read_image_request() if data is None: return @@ -2249,7 +2267,7 @@ class Handler(BaseHTTPRequestHandler): "invalid_request_error", "too_many_images") return - source_files: list[str] = [] + decoded: list[bytes] = [] try: for item in encoded: if item.startswith("data:"): @@ -2260,6 +2278,25 @@ class Handler(BaseHTTPRequestHandler): raw = base64.b64decode(item, validate=True) except Exception as exc: raise ValueError("ungültige Base64-Bilddaten") from exc + decoded.append(raw) + self._image_edit_bytes(data, decoded) + except ValueError as exc: + self._send_error(400, str(exc), + "invalid_request_error", "invalid_image") + + def _image_edit_bytes(self, data: dict, images: list[bytes]) -> None: + if len(images) > 4: + self._send_error(400, "höchstens vier Referenzbilder erlaubt", + "invalid_request_error", "too_many_images") + return + if not images: + self._send_error(400, "Referenzbild fehlt", + "invalid_request_error", "missing_image") + return + + source_files: list[str] = [] + try: + for raw in images: if not raw or len(raw) > CHAT_IMAGE_MAX_BYTES: raise ValueError( f"Referenzbild muss 1..{CHAT_IMAGE_MAX_BYTES} Bytes groß sein") @@ -2652,9 +2689,9 @@ class Handler(BaseHTTPRequestHandler): # ---------- STT (Spracherkennung) ---------- - def _parse_multipart(self, data: bytes, content_type: str - ) -> tuple[bytes, str, dict]: - """Parst multipart/form-data. Liefert (file_data, filename, fields). + def _parse_multipart_parts(self, data: bytes, content_type: str + ) -> tuple[list[tuple[str, str, bytes]], dict]: + """Parst alle Datei- und Textteile eines multipart/form-data-Body. Nutzt email.parser.BytesParser (Standardbibliothek) für robustes MIME-Parsing. Handhabt quoted und unquoted Boundaries, beliebige @@ -2667,9 +2704,8 @@ class Handler(BaseHTTPRequestHandler): if not msg.is_multipart(): raise ValueError("Kein multipart/form-data") - file_data = b"" - filename = "" - fields = {} + files: list[tuple[str, str, bytes]] = [] + fields: dict[str, str] = {} for part in msg.get_payload(): disposition = part.get("Content-Disposition", "") @@ -2690,12 +2726,20 @@ class Handler(BaseHTTPRequestHandler): if part_filename is not None: # Dateifeld (binär, nicht dekodieren) - file_data = payload - filename = part_filename or "" + files.append((name, part_filename or "", payload)) else: # Textfeld fields[name] = payload.decode("utf-8", errors="replace") + return files, fields + + def _parse_multipart(self, data: bytes, content_type: str + ) -> tuple[bytes, str, dict]: + """Kompatibler Einzeldatei-Wrapper für den STT-Pfad.""" + files, fields = self._parse_multipart_parts(data, content_type) + if not files: + return b"", "", fields + _name, filename, file_data = files[-1] return file_data, filename, fields def _transcribe(self) -> None: